feat(api): manage restrictions for users

This commit is contained in:
Gauthier Roebroeck 2022-03-03 08:51:19 +08:00
parent 5ecc9c6785
commit e345d6f9ef
9 changed files with 329 additions and 22 deletions

View file

@ -1,35 +1,39 @@
package org.gotson.komga.domain.model
sealed class ContentRestriction {
sealed class AgeRestriction(val age: Int) : ContentRestriction() {
sealed class AgeRestriction : ContentRestriction() {
abstract val age: Int
/**
* Allow only content that has an age rating equal to or under the provided [age]
*
* @param[age] the age rating to allow
*/
class AllowOnlyUnder(age: Int) : AgeRestriction(age)
data class AllowOnlyUnder(override val age: Int) : AgeRestriction()
/**
* Exclude content that has an age rating equal to or over the provided [age]
*
* @param[age] the age rating to exclude
*/
class ExcludeOver(age: Int) : AgeRestriction(age)
data class ExcludeOver(override val age: Int) : AgeRestriction()
}
sealed class LabelsRestriction(val labels: Set<String>) : ContentRestriction() {
sealed class LabelsRestriction : ContentRestriction() {
abstract val labels: Set<String>
/**
* Allow only content that has at least one of the provided sharing [labels]
*
* @param[labels] a set of sharing labels to allow access to
*/
class AllowOnly(labels: Set<String>) : LabelsRestriction(labels)
data class AllowOnly(override val labels: Set<String>) : LabelsRestriction()
/**
* Exclude content that has at least one of the provided sharing [labels]
*
* @param[labels] a set of sharing labels to exclude
*/
class Exclude(labels: Set<String>) : LabelsRestriction(labels)
data class Exclude(override val labels: Set<String>) : LabelsRestriction()
}
}

View file

@ -2,12 +2,13 @@ package org.gotson.komga.domain.model
import org.gotson.komga.language.lowerNotBlank
import org.gotson.komga.language.toSetOrNull
import java.io.Serializable
class ContentRestrictions(
val ageRestriction: ContentRestriction.AgeRestriction? = null,
labelsAllow: Set<String> = emptySet(),
labelsExclude: Set<String> = emptySet(),
) {
) : Serializable {
val labelsAllowRestriction =
labelsAllow.lowerNotBlank().toSet()
.minus(labelsExclude.lowerNotBlank().toSet())
@ -19,4 +20,22 @@ class ContentRestrictions(
fun isRestricted() = ageRestriction != null || labelsAllowRestriction != null || labelsExcludeRestriction != null
override fun toString(): String = "ContentRestriction(ageRestriction=$ageRestriction, labelsAllowRestriction=$labelsAllowRestriction, labelsExcludeRestriction=$labelsExcludeRestriction)"
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is ContentRestrictions) return false
if (ageRestriction != other.ageRestriction) return false
if (labelsAllowRestriction != other.labelsAllowRestriction) return false
if (labelsExcludeRestriction != other.labelsExcludeRestriction) return false
return true
}
override fun hashCode(): Int {
var result = ageRestriction?.hashCode() ?: 0
result = 31 * result + (labelsAllowRestriction?.hashCode() ?: 0)
result = 31 * result + (labelsExcludeRestriction?.hashCode() ?: 0)
return result
}
}

View file

@ -43,4 +43,7 @@ sealed class DomainEvent : Serializable {
data class ThumbnailReadListAdded(val thumbnail: ThumbnailReadList) : DomainEvent()
data class ThumbnailReadListDeleted(val thumbnail: ThumbnailReadList) : DomainEvent()
data class UserUpdated(val user: KomgaUser, val expireSession: Boolean) : DomainEvent()
data class UserDeleted(val user: KomgaUser) : DomainEvent()
}

View file

@ -1,6 +1,8 @@
package org.gotson.komga.domain.service
import mu.KotlinLogging
import org.gotson.komga.application.events.EventPublisher
import org.gotson.komga.domain.model.DomainEvent
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.UserEmailAlreadyExistsException
import org.gotson.komga.domain.persistence.AuthenticationActivityRepository
@ -22,7 +24,7 @@ class KomgaUserLifecycle(
private val passwordEncoder: PasswordEncoder,
private val sessionRegistry: SessionRegistry,
private val transactionTemplate: TransactionTemplate,
private val eventPublisher: EventPublisher,
) {
fun updatePassword(user: KomgaUser, newPassword: String, expireSessions: Boolean) {
@ -31,6 +33,26 @@ class KomgaUserLifecycle(
userRepository.update(updatedUser)
if (expireSessions) expireSessions(updatedUser)
eventPublisher.publishEvent(DomainEvent.UserUpdated(updatedUser, expireSessions))
}
fun updateUser(user: KomgaUser) {
val existing = userRepository.findByIdOrNull(user.id)
requireNotNull(existing) { "User doesn't exist, cannot update: $user" }
val toUpdate = user.copy(password = existing.password)
logger.info { "Update user: $toUpdate" }
userRepository.update(toUpdate)
val expireSessions = existing.roles() != user.roles() ||
existing.restrictions != user.restrictions ||
existing.sharedAllLibraries != user.sharedAllLibraries ||
existing.sharedLibrariesIds != user.sharedLibrariesIds
if (expireSessions) expireSessions(toUpdate)
eventPublisher.publishEvent(DomainEvent.UserUpdated(toUpdate, expireSessions))
}
fun countUsers() = userRepository.count()
@ -56,9 +78,11 @@ class KomgaUserLifecycle(
}
expireSessions(user)
eventPublisher.publishEvent(DomainEvent.UserUpdated(user, true))
}
private fun expireSessions(user: KomgaUser) {
fun expireSessions(user: KomgaUser) {
logger.info { "Expiring all sessions for user: ${user.email}" }
sessionRegistry
.getAllSessions(KomgaPrincipal(user), false)

View file

@ -124,7 +124,7 @@ class UserV1Controller(
roleFileDownload = patch.roles.contains(ROLE_FILE_DOWNLOAD),
rolePageStreaming = patch.roles.contains(ROLE_PAGE_STREAMING),
)
userRepository.update(updatedUser)
userLifecycle.updateUser(updatedUser)
logger.info { "Updated user roles: $updatedUser" }
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}
@ -160,7 +160,7 @@ class UserV1Controller(
.map { it.id }
.toSet(),
)
userRepository.update(updatedUser)
userLifecycle.updateUser(updatedUser)
logger.info { "Updated user shared libraries: $updatedUser" }
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}

View file

@ -2,6 +2,8 @@ package org.gotson.komga.interfaces.api.rest
import io.swagger.v3.oas.annotations.Parameter
import mu.KotlinLogging
import org.gotson.komga.domain.model.ContentRestriction
import org.gotson.komga.domain.model.ContentRestrictions
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
@ -12,6 +14,7 @@ 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.AllowExclude
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
@ -118,10 +121,22 @@ class UserV2Controller(
if (sharedLibraries!!.all) emptySet()
else libraryRepository.findAllByIds(sharedLibraries!!.libraryIds).map { it.id }.toSet()
} else existing.sharedLibrariesIds,
restrictions = ContentRestrictions(
ageRestriction = if (isSet("ageRestriction")) {
if (ageRestriction == null) null
else when (ageRestriction!!.restriction) {
AllowExclude.ALLOW_ONLY -> ContentRestriction.AgeRestriction.AllowOnlyUnder(ageRestriction!!.age)
AllowExclude.EXCLUDE -> ContentRestriction.AgeRestriction.ExcludeOver(ageRestriction!!.age)
}
} else existing.restrictions.ageRestriction,
labelsAllow = if (isSet("labelsAllow")) labelsAllow
?: emptySet() else existing.restrictions.labelsAllowRestriction?.labels ?: emptySet(),
labelsExclude = if (isSet("labelsExclude")) labelsExclude
?: emptySet() else existing.restrictions.labelsExcludeRestriction?.labels ?: emptySet(),
),
)
}
userRepository.update(updatedUser)
logger.info { "Updated user: $updatedUser" }
userLifecycle.updateUser(updatedUser)
} ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
}

View file

@ -1,5 +1,8 @@
@file:Suppress("DEPRECATION")
package org.gotson.komga.interfaces.api.rest.dto
import org.gotson.komga.domain.model.ContentRestriction
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.ROLE_ADMIN
import org.gotson.komga.domain.model.ROLE_FILE_DOWNLOAD
@ -32,8 +35,20 @@ data class UserDtoV2(
val roles: Set<String>,
val sharedAllLibraries: Boolean,
val sharedLibrariesIds: Set<String>,
val labelsAllow: Set<String>,
val labelsExclude: Set<String>,
val ageRestriction: AgeRestrictionDto?,
)
data class AgeRestrictionDto(
val age: Int,
val restriction: AllowExclude,
)
enum class AllowExclude {
ALLOW_ONLY, EXCLUDE,
}
fun KomgaUser.toDtoV2() =
UserDtoV2(
id = id,
@ -41,6 +56,13 @@ fun KomgaUser.toDtoV2() =
roles = roles(),
sharedAllLibraries = sharedAllLibraries,
sharedLibrariesIds = sharedLibrariesIds,
labelsAllow = restrictions.labelsAllowRestriction?.labels ?: emptySet(),
labelsExclude = restrictions.labelsExcludeRestriction?.labels ?: emptySet(),
ageRestriction = when (restrictions.ageRestriction) {
is ContentRestriction.AgeRestriction.AllowOnlyUnder -> AgeRestrictionDto(restrictions.ageRestriction.age, AllowExclude.ALLOW_ONLY)
is ContentRestriction.AgeRestriction.ExcludeOver -> AgeRestrictionDto(restrictions.ageRestriction.age, AllowExclude.EXCLUDE)
null -> null
}
)
fun KomgaPrincipal.toDtoV2() = user.toDtoV2()

View file

@ -1,19 +1,28 @@
package org.gotson.komga.interfaces.api.rest.dto
import javax.validation.Valid
import javax.validation.constraints.PositiveOrZero
import kotlin.properties.Delegates
class UserUpdateDto {
private val isSet = mutableMapOf<String, Boolean>()
fun isSet(prop: String) = isSet.getOrDefault(prop, false)
// @get:NullOrNotBlank
// val title: String? = null
@get:Valid
var ageRestriction: AgeRestrictionUpdateDto?
by Delegates.observable(null) { prop, _, _ ->
isSet[prop.name] = true
}
// @get:PositiveOrZero
// var ageRating: Int?
// by Delegates.observable(null) { prop, _, _ ->
// isSet[prop.name] = true
// }
var labelsAllow: Set<String>?
by Delegates.observable(null) { prop, _, _ ->
isSet[prop.name] = true
}
var labelsExclude: Set<String>?
by Delegates.observable(null) { prop, _, _ ->
isSet[prop.name] = true
}
var roles: Set<String>?
by Delegates.observable(null) { prop, _, _ ->
@ -26,6 +35,12 @@ class UserUpdateDto {
}
}
data class AgeRestrictionUpdateDto(
@get:PositiveOrZero
val age: Int,
val restriction: AllowExclude,
)
data class SharedLibrariesUpdateDto(
val all: Boolean,
val libraryIds: Set<String>,

View file

@ -1,6 +1,10 @@
package org.gotson.komga.interfaces.api.rest
import com.ninjasquad.springmockk.SpykBean
import io.mockk.verify
import org.assertj.core.api.Assertions.assertThat
import org.gotson.komga.domain.model.ContentRestriction
import org.gotson.komga.domain.model.ContentRestrictions
import org.gotson.komga.domain.model.KomgaUser
import org.gotson.komga.domain.model.ROLE_ADMIN
import org.gotson.komga.domain.model.ROLE_FILE_DOWNLOAD
@ -37,8 +41,11 @@ class UserControllerTest(
@Autowired private val libraryRepository: LibraryRepository,
@Autowired private val libraryLifecycle: LibraryLifecycle,
@Autowired private val userRepository: KomgaUserRepository,
@Autowired private val userLifecycle: KomgaUserLifecycle,
) {
@SpykBean
private lateinit var userLifecycle: KomgaUserLifecycle
private val admin = KomgaUser("admin@example.org", "", true, id = "admin")
@BeforeAll
@ -106,6 +113,8 @@ class UserControllerTest(
assertThat(this.rolePageStreaming).isTrue
assertThat(this.roleAdmin).isFalse
}
verify(exactly = 1) { userLifecycle.expireSessions(any()) }
}
@Test
@ -133,6 +142,8 @@ class UserControllerTest(
assertThat(this.rolePageStreaming).isFalse
assertThat(this.roleAdmin).isFalse
}
verify(exactly = 1) { userLifecycle.expireSessions(any()) }
}
@Test
@ -162,6 +173,8 @@ class UserControllerTest(
assertThat(this!!.sharedAllLibraries).isFalse
assertThat(this.sharedLibrariesIds).containsExactlyInAnyOrder("1", "2")
}
verify(exactly = 1) { userLifecycle.expireSessions(any()) }
}
@Test
@ -191,11 +204,13 @@ class UserControllerTest(
assertThat(this!!.sharedAllLibraries).isFalse
assertThat(this.sharedLibrariesIds).containsExactlyInAnyOrder("2")
}
verify(exactly = 1) { userLifecycle.expireSessions(any()) }
}
@Test
@WithMockCustomUser(id = "admin", roles = [ROLE_ADMIN])
fun `given user with library restrictions when removing restrictions then they restrictions are updated`() {
fun `given user with library restrictions when removing restrictions then the restrictions are updated`() {
val user = KomgaUser("user@example.org", "", false, id = "user", sharedAllLibraries = false, sharedLibrariesIds = setOf("2"))
userLifecycle.createUser(user)
@ -220,6 +235,196 @@ class UserControllerTest(
assertThat(this!!.sharedAllLibraries).isTrue
assertThat(this.sharedLibrariesIds).isEmpty()
}
verify(exactly = 1) { userLifecycle.expireSessions(any()) }
}
@Test
@WithMockCustomUser(id = "admin", roles = [ROLE_ADMIN])
fun `given user without labels restrictions when adding restrictions then restrictions are updated`() {
val user = KomgaUser("user@example.org", "", false, id = "user")
userLifecycle.createUser(user)
val jsonString = """
{
"labelsAllow": ["cute", "kids"],
"labelsExclude": ["adult"]
}
""".trimIndent()
mockMvc.patch("/api/v2/users/${user.id}") {
contentType = MediaType.APPLICATION_JSON
content = jsonString
}.andExpect {
status { isNoContent() }
}
with(userRepository.findByIdOrNull(user.id)) {
assertThat(this).isNotNull
assertThat(this!!.restrictions.labelsAllowRestriction!!.labels).containsExactlyInAnyOrder("cute", "kids")
assertThat(this.restrictions.labelsExcludeRestriction!!.labels).containsOnly("adult")
}
verify(exactly = 1) { userLifecycle.expireSessions(any()) }
}
@Test
@WithMockCustomUser(id = "admin", roles = [ROLE_ADMIN])
fun `given user with labels restrictions when removing restrictions then restrictions are updated`() {
val user = KomgaUser(
"user@example.org", "", false, id = "user",
restrictions = ContentRestrictions(
labelsAllow = setOf("kids", "cute"),
labelsExclude = setOf("adult"),
),
)
userLifecycle.createUser(user)
val jsonString = """
{
"labelsAllow": [],
"labelsExclude": null
}
""".trimIndent()
mockMvc.patch("/api/v2/users/${user.id}") {
contentType = MediaType.APPLICATION_JSON
content = jsonString
}.andExpect {
status { isNoContent() }
}
with(userRepository.findByIdOrNull(user.id)) {
assertThat(this).isNotNull
assertThat(this!!.restrictions.labelsAllowRestriction).isNull()
assertThat(this.restrictions.labelsExcludeRestriction).isNull()
}
verify(exactly = 1) { userLifecycle.expireSessions(any()) }
}
@Test
@WithMockCustomUser(id = "admin", roles = [ROLE_ADMIN])
fun `given user without age restriction when adding restrictions then restrictions are updated`() {
val user = KomgaUser("user@example.org", "", false, id = "user")
userLifecycle.createUser(user)
val jsonString = """
{
"ageRestriction": {
"age": 12,
"restriction": "ALLOW_ONLY"
}
}
""".trimIndent()
mockMvc.patch("/api/v2/users/${user.id}") {
contentType = MediaType.APPLICATION_JSON
content = jsonString
}.andExpect {
status { isNoContent() }
}
with(userRepository.findByIdOrNull(user.id)) {
assertThat(this).isNotNull
assertThat(this!!.restrictions.ageRestriction).isNotNull
assertThat(this.restrictions.ageRestriction).isInstanceOf(ContentRestriction.AgeRestriction.AllowOnlyUnder::class.java)
assertThat(this.restrictions.ageRestriction!!.age).isEqualTo(12)
}
verify(exactly = 1) { userLifecycle.expireSessions(any()) }
}
@Test
@WithMockCustomUser(id = "admin", roles = [ROLE_ADMIN])
fun `given user without age restriction when adding incorrect restrictions then bad request`() {
val user = KomgaUser("user@example.org", "", false, id = "user")
userLifecycle.createUser(user)
val jsonString = """
{
"ageRestriction": {
"age": -12,
"restriction": "ALLOW_ONLY"
}
}
""".trimIndent()
mockMvc.patch("/api/v2/users/${user.id}") {
contentType = MediaType.APPLICATION_JSON
content = jsonString
}.andExpect {
status { isBadRequest() }
}
}
@Test
@WithMockCustomUser(id = "admin", roles = [ROLE_ADMIN])
fun `given user with age restriction when removing restriction then restrictions are updated`() {
val user = KomgaUser(
"user@example.org", "", false, id = "user",
restrictions = ContentRestrictions(
ageRestriction = ContentRestriction.AgeRestriction.AllowOnlyUnder(12),
),
)
userLifecycle.createUser(user)
val jsonString = """
{
"ageRestriction": null
}
""".trimIndent()
mockMvc.patch("/api/v2/users/${user.id}") {
contentType = MediaType.APPLICATION_JSON
content = jsonString
}.andExpect {
status { isNoContent() }
}
with(userRepository.findByIdOrNull(user.id)) {
assertThat(this).isNotNull
assertThat(this!!.restrictions.ageRestriction).isNull()
}
verify(exactly = 1) { userLifecycle.expireSessions(any()) }
}
@Test
@WithMockCustomUser(id = "admin", roles = [ROLE_ADMIN])
fun `given user with age restriction when changing restriction then restrictions are updated`() {
val user = KomgaUser(
"user@example.org", "", false, id = "user",
restrictions = ContentRestrictions(
ageRestriction = ContentRestriction.AgeRestriction.AllowOnlyUnder(12),
),
)
userLifecycle.createUser(user)
val jsonString = """
{
"ageRestriction": {
"age": 16,
"restriction": "EXCLUDE"
}
}
""".trimIndent()
mockMvc.patch("/api/v2/users/${user.id}") {
contentType = MediaType.APPLICATION_JSON
content = jsonString
}.andExpect {
status { isNoContent() }
}
with(userRepository.findByIdOrNull(user.id)) {
assertThat(this).isNotNull
assertThat(this!!.restrictions.ageRestriction).isNotNull
assertThat(this.restrictions.ageRestriction).isExactlyInstanceOf(ContentRestriction.AgeRestriction.ExcludeOver::class.java)
assertThat(this.restrictions.ageRestriction!!.age).isEqualTo(16)
}
verify(exactly = 1) { userLifecycle.expireSessions(any()) }
}
}
}