#30 Random group token UUIDs to prevent critical security problem.
This commit is contained in:
parent
69c5d86d45
commit
b235b5a845
|
@ -18,7 +18,7 @@ import javax.servlet.http.HttpServletResponse
|
|||
abstract class JwtAuthenticationFilter(
|
||||
filterProcessesUrl: String,
|
||||
private val securityProperties: CreSecurityProperties,
|
||||
protected val jwtLogic: JwtLogic
|
||||
private val jwtLogic: JwtLogic
|
||||
) :
|
||||
AbstractAuthenticationProcessingFilter(
|
||||
AntPathRequestMatcher(filterProcessesUrl, HttpMethod.POST.toString())
|
||||
|
@ -73,4 +73,4 @@ abstract class JwtAuthenticationFilter(
|
|||
private const val AUTHORIZATION_COOKIE_SAME_SITE = true
|
||||
private const val AUTHORIZATION_COOKIE_PATH = Constants.ControllerPaths.BASE_PATH
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,6 +10,8 @@ data class GroupTokenDto(
|
|||
|
||||
val enabled: Boolean,
|
||||
|
||||
val isDeleted: Boolean,
|
||||
|
||||
val group: GroupDto
|
||||
)
|
||||
|
||||
|
|
|
@ -32,8 +32,7 @@ class DefaultGroupTokenLogic(
|
|||
private val groupLogic: GroupLogic,
|
||||
private val jwtLogic: JwtLogic,
|
||||
private val enabledTokensCache: HashSet<String> = hashSetOf() // In constructor for unit testing
|
||||
) :
|
||||
GroupTokenLogic {
|
||||
) : GroupTokenLogic {
|
||||
private val typeName = Constants.ModelNames.GROUP_TOKEN
|
||||
private val typeNameLowerCase = typeName.lowercase()
|
||||
|
||||
|
@ -49,8 +48,7 @@ class DefaultGroupTokenLogic(
|
|||
override fun getById(id: UUID) = service.getById(id) ?: throw notFoundException(value = id)
|
||||
|
||||
override fun getIdForRequest(request: HttpServletRequest): UUID? {
|
||||
val groupTokenCookie = getGroupTokenCookie(request)
|
||||
?: return null
|
||||
val groupTokenCookie = getGroupTokenCookie(request) ?: return null
|
||||
|
||||
val jwt = parseBearer(groupTokenCookie.value)
|
||||
return jwtLogic.parseGroupTokenIdJwt(jwt)
|
||||
|
@ -59,10 +57,9 @@ class DefaultGroupTokenLogic(
|
|||
override fun save(dto: GroupTokenSaveDto): GroupTokenDto {
|
||||
throwIfNameAlreadyExists(dto.name)
|
||||
|
||||
// We don't need to check for collision, because UUIDs with different names will be different
|
||||
val id = generateUUIDForName(dto.name)
|
||||
val id = generateRandomUUID()
|
||||
val token = GroupTokenDto(
|
||||
id, dto.name, true, groupLogic.getById(dto.groupId)
|
||||
id, dto.name, enabled = true, isDeleted = false, group = groupLogic.getById(dto.groupId)
|
||||
)
|
||||
|
||||
val savedToken = service.save(token)
|
||||
|
@ -84,15 +81,26 @@ class DefaultGroupTokenLogic(
|
|||
}
|
||||
|
||||
override fun deleteById(id: String) {
|
||||
val token = getById(id).copy(enabled = false, isDeleted = true)
|
||||
|
||||
service.save(token)
|
||||
enabledTokensCache.remove(id)
|
||||
service.deleteById(UUID.fromString(id))
|
||||
}
|
||||
|
||||
private fun setEnabled(id: String, enabled: Boolean) = with(getById(id)) {
|
||||
service.save(this.copy(enabled = enabled))
|
||||
}
|
||||
|
||||
private fun generateUUIDForName(name: String) = UUID.nameUUIDFromBytes(name.toByteArray())
|
||||
private fun generateRandomUUID(): UUID {
|
||||
var uuid = UUID.randomUUID()
|
||||
|
||||
// The UUID specification doesn't guarantee to prevent collisions
|
||||
while (service.existsById(uuid)) {
|
||||
uuid = UUID.randomUUID()
|
||||
}
|
||||
|
||||
return uuid
|
||||
}
|
||||
|
||||
private fun throwIfNameAlreadyExists(name: String) {
|
||||
if (service.existsByName(name)) {
|
||||
|
|
|
@ -20,6 +20,9 @@ data class GroupToken(
|
|||
@Column(name = "is_valid")
|
||||
val isValid: Boolean,
|
||||
|
||||
@Column(name = "deleted")
|
||||
val isDeleted: Boolean,
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "group_id")
|
||||
val group: Group
|
||||
|
|
|
@ -39,6 +39,12 @@ interface GroupRepository : JpaRepository<Group, Long> {
|
|||
|
||||
@Repository
|
||||
interface GroupTokenRepository : JpaRepository<GroupToken, UUID> {
|
||||
/** Checks if a token with the given [name] exists. */
|
||||
fun existsByName(name: String): Boolean
|
||||
/** Checks if a token that is not deleted with the given [name] exists. */
|
||||
fun existsByNameAndIsDeletedIsFalse(name: String): Boolean
|
||||
|
||||
/** Finds all group tokens that are not deleted. */
|
||||
fun findAllByIsDeletedIsFalse(): Collection<GroupToken>
|
||||
|
||||
/** Finds the group token with the given [id] if it is not deleted. */
|
||||
fun findByIdAndIsDeletedIsFalse(id: UUID): GroupToken?
|
||||
}
|
||||
|
|
|
@ -4,7 +4,6 @@ import dev.fyloz.colorrecipesexplorer.config.annotations.ServiceComponent
|
|||
import dev.fyloz.colorrecipesexplorer.dtos.account.GroupTokenDto
|
||||
import dev.fyloz.colorrecipesexplorer.model.account.GroupToken
|
||||
import dev.fyloz.colorrecipesexplorer.repository.GroupTokenRepository
|
||||
import org.springframework.data.repository.findByIdOrNull
|
||||
import java.util.UUID
|
||||
|
||||
interface GroupTokenService {
|
||||
|
@ -23,12 +22,12 @@ interface GroupTokenService {
|
|||
class DefaultGroupTokenService(private val repository: GroupTokenRepository, private val groupService: GroupService) :
|
||||
GroupTokenService {
|
||||
override fun existsById(id: UUID) = repository.existsById(id)
|
||||
override fun existsByName(name: String) = repository.existsByName(name)
|
||||
override fun existsByName(name: String) = repository.existsByNameAndIsDeletedIsFalse(name)
|
||||
|
||||
override fun getAll() = repository.findAll().map(::toDto)
|
||||
override fun getAll() = repository.findAllByIsDeletedIsFalse().map(::toDto)
|
||||
|
||||
override fun getById(id: UUID): GroupTokenDto? {
|
||||
val entity = repository.findByIdOrNull(id)
|
||||
val entity = repository.findByIdAndIsDeletedIsFalse(id)
|
||||
|
||||
return if (entity != null) toDto(entity) else null
|
||||
}
|
||||
|
@ -41,8 +40,8 @@ class DefaultGroupTokenService(private val repository: GroupTokenRepository, pri
|
|||
override fun deleteById(id: UUID) = repository.deleteById(id)
|
||||
|
||||
override fun toDto(entity: GroupToken) =
|
||||
GroupTokenDto(entity.id, entity.name, entity.isValid, groupService.toDto(entity.group))
|
||||
GroupTokenDto(entity.id, entity.name, entity.isValid, entity.isDeleted, groupService.toDto(entity.group))
|
||||
|
||||
override fun toEntity(dto: GroupTokenDto) =
|
||||
GroupToken(dto.id, dto.name, dto.enabled, groupService.toEntity(dto.group))
|
||||
GroupToken(dto.id, dto.name, dto.enabled, dto.isDeleted, groupService.toEntity(dto.group))
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package dev.fyloz.colorrecipesexplorer.logic.account
|
||||
|
||||
import dev.fyloz.colorrecipesexplorer.Constants
|
||||
import dev.fyloz.colorrecipesexplorer.dtos.account.GroupDto
|
||||
import dev.fyloz.colorrecipesexplorer.dtos.account.GroupTokenDto
|
||||
import dev.fyloz.colorrecipesexplorer.dtos.account.GroupTokenSaveDto
|
||||
|
@ -10,22 +11,27 @@ import io.mockk.*
|
|||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
import org.springframework.web.util.WebUtils
|
||||
import java.util.*
|
||||
import javax.servlet.http.Cookie
|
||||
import javax.servlet.http.HttpServletRequest
|
||||
import kotlin.test.*
|
||||
|
||||
class DefaultGroupTokenLogicTest {
|
||||
private val groupTokenServiceMock = mockk<GroupTokenService>()
|
||||
private val groupLogicMock = mockk<GroupLogic>()
|
||||
private val jwtLogicMock = mockk<JwtLogic>()
|
||||
|
||||
private val enabledTokenCache = hashSetOf<String>()
|
||||
|
||||
private val groupTokenLogic = spyk(DefaultGroupTokenLogic(groupTokenServiceMock, groupLogicMock, enabledTokenCache))
|
||||
private val groupTokenLogic =
|
||||
spyk(DefaultGroupTokenLogic(groupTokenServiceMock, groupLogicMock, jwtLogicMock, enabledTokenCache))
|
||||
|
||||
private val groupTokenName = "Unit test token"
|
||||
private val groupTokenId = UUID.nameUUIDFromBytes(groupTokenName.toByteArray())
|
||||
private val groupTokenIdStr = groupTokenId.toString()
|
||||
private val group = GroupDto(1L, "Unit test group", listOf(), listOf())
|
||||
private val groupToken = GroupTokenDto(groupTokenId, groupTokenName, true, group)
|
||||
private val groupToken = GroupTokenDto(groupTokenId, groupTokenName, true, false, group)
|
||||
private val groupTokenSaveDto = GroupTokenSaveDto(groupTokenName, group.id)
|
||||
|
||||
@AfterEach
|
||||
|
@ -106,15 +112,31 @@ class DefaultGroupTokenLogicTest {
|
|||
assertThrows<NotFoundException> { groupTokenLogic.getById(groupTokenId) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getIdForRequest_normalBehavior_returnsGroupTokenIdFromRequest() {
|
||||
// Arrange
|
||||
val request = mockk<HttpServletRequest>()
|
||||
val cookie = mockk<Cookie> {
|
||||
every { value } returns "Bearer$groupTokenIdStr"
|
||||
}
|
||||
|
||||
mockkStatic(WebUtils::class) {
|
||||
every { WebUtils.getCookie(any(), Constants.CookieNames.GROUP_TOKEN) } returns cookie
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun save_normalBehavior_callsSaveInService() {
|
||||
// Arrange
|
||||
every { groupTokenServiceMock.existsByName(any()) } returns false
|
||||
every { groupTokenServiceMock.existsById(any()) } returns false
|
||||
every { groupTokenServiceMock.save(any()) } returns groupToken
|
||||
every { groupLogicMock.getById(any()) } returns group
|
||||
|
||||
// Act
|
||||
groupTokenLogic.save(groupTokenSaveDto)
|
||||
withMockRandomUUID {
|
||||
groupTokenLogic.save(groupTokenSaveDto)
|
||||
}
|
||||
|
||||
// Assert
|
||||
verify {
|
||||
|
@ -122,15 +144,41 @@ class DefaultGroupTokenLogicTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun save_idAlreadyExists_generatesNewId() {
|
||||
// Arrange
|
||||
every { groupTokenServiceMock.existsByName(any()) } returns false
|
||||
every { groupTokenServiceMock.existsById(any()) } returnsMany listOf(true, false)
|
||||
every { groupTokenServiceMock.save(any()) } returns groupToken
|
||||
every { groupLogicMock.getById(any()) } returns group
|
||||
|
||||
val anotherGroupTokenId = UUID.nameUUIDFromBytes("Another unit test token".toByteArray())
|
||||
|
||||
// Act
|
||||
withMockRandomUUID(listOf(groupTokenId, anotherGroupTokenId)) {
|
||||
groupTokenLogic.save(groupTokenSaveDto)
|
||||
}
|
||||
|
||||
// Assert
|
||||
verify {
|
||||
groupTokenServiceMock.save(match {
|
||||
it.id == anotherGroupTokenId
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun save_normalBehavior_addsIdToEnabledTokensCache() {
|
||||
// Arrange
|
||||
every { groupTokenServiceMock.existsByName(any()) } returns false
|
||||
every { groupTokenServiceMock.existsById(any()) } returns false
|
||||
every { groupTokenServiceMock.save(any()) } returns groupToken
|
||||
every { groupLogicMock.getById(any()) } returns group
|
||||
|
||||
// Act
|
||||
groupTokenLogic.save(groupTokenSaveDto)
|
||||
withMockRandomUUID {
|
||||
groupTokenLogic.save(groupTokenSaveDto)
|
||||
}
|
||||
|
||||
// Assert
|
||||
assertContains(enabledTokenCache, groupTokenIdStr)
|
||||
|
@ -207,23 +255,27 @@ class DefaultGroupTokenLogicTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun deleteById_normalBehavior_callsService() {
|
||||
fun deleteById_normalBehavior_savesDeletedTokenInService() {
|
||||
// Arrange
|
||||
every { groupTokenServiceMock.deleteById(any()) } just runs
|
||||
every { groupTokenLogic.getById(any<String>()) } answers { groupToken }
|
||||
every { groupTokenServiceMock.save(any()) } answers { firstArg() }
|
||||
|
||||
// Act
|
||||
groupTokenLogic.deleteById(groupTokenIdStr)
|
||||
|
||||
// Assert
|
||||
verify {
|
||||
groupTokenServiceMock.deleteById(groupTokenId)
|
||||
groupTokenServiceMock.save(match {
|
||||
it.id == groupTokenId && it.isDeleted
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteById_normalBehavior_removesIdFromEnabledTokensCache() {
|
||||
// Arrange
|
||||
every { groupTokenServiceMock.deleteById(any()) } just runs
|
||||
every { groupTokenLogic.getById(any<String>()) } answers { groupToken }
|
||||
every { groupTokenServiceMock.save(any()) } answers { firstArg() }
|
||||
|
||||
// Act
|
||||
groupTokenLogic.deleteById(groupTokenIdStr)
|
||||
|
@ -231,4 +283,16 @@ class DefaultGroupTokenLogicTest {
|
|||
// Assert
|
||||
assertFalse(enabledTokenCache.contains(groupTokenIdStr))
|
||||
}
|
||||
|
||||
private fun withMockRandomUUID(uuids: List<UUID>? = null, block: () -> Unit) {
|
||||
mockkStatic(UUID::class) {
|
||||
if (uuids == null) {
|
||||
every { UUID.randomUUID() } returns groupTokenId
|
||||
} else {
|
||||
every { UUID.randomUUID() } returnsMany uuids
|
||||
}
|
||||
|
||||
block()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue