From a3b49804a5d52937b7421718ebdf46586813d540 Mon Sep 17 00:00:00 2001 From: william Date: Mon, 25 Apr 2022 22:05:05 -0400 Subject: [PATCH 01/10] #30 Add group tokens --- .../fyloz/colorrecipesexplorer/Constants.kt | 8 +- .../annotations/PermissionAnnotations.kt | 6 ++ .../config/security/JwtFilters.kt | 10 +-- .../config/security/SecurityConfig.kt | 8 +- .../colorrecipesexplorer/dtos/RecipeDto.kt | 3 +- .../dtos/{ => account}/GroupDto.kt | 3 +- .../dtos/account/GroupTokenDto.kt | 21 +++++ .../dtos/{ => account}/UserDto.kt | 7 +- .../colorrecipesexplorer/logic/RecipeLogic.kt | 2 +- .../logic/RecipeStepLogic.kt | 5 +- .../logic/{users => account}/GroupLogic.kt | 10 +-- .../logic/account/GroupTokenLogic.kt | 85 +++++++++++++++++++ .../logic/{users => account}/JwtLogic.kt | 6 +- .../{users => account}/UserDetailsLogic.kt | 6 +- .../logic/{users => account}/UserLogic.kt | 15 ++-- .../model/account/GroupToken.kt | 26 ++++++ .../repository/AccountRepository.kt | 8 ++ .../rest/account/GroupController.kt | 82 ++++++++++++++++++ .../rest/account/GroupTokenController.kt | 40 +++++++++ .../UserController.kt} | 80 ++--------------- .../service/RecipeService.kt | 3 +- .../service/{ => account}/GroupService.kt | 8 +- .../service/account/GroupTokenService.kt | 48 +++++++++++ .../service/{ => account}/UserService.kt | 10 ++- .../logic/DefaultJwtLogicTest.kt | 8 +- .../logic/DefaultRecipeLogicTest.kt | 4 +- .../logic/DefaultRecipeStepLogicTest.kt | 4 +- .../logic/account/DefaultGroupLogicTest.kt | 10 +-- .../logic/account/DefaultUserLogicTest.kt | 14 ++- 29 files changed, 397 insertions(+), 143 deletions(-) rename src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/{ => account}/GroupDto.kt (85%) create mode 100644 src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/GroupTokenDto.kt rename src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/{ => account}/UserDto.kt (93%) rename src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/{users => account}/GroupLogic.kt (92%) create mode 100644 src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupTokenLogic.kt rename src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/{users => account}/JwtLogic.kt (92%) rename src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/{users => account}/UserDetailsLogic.kt (93%) rename src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/{users => account}/UserLogic.kt (94%) create mode 100644 src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/account/GroupToken.kt create mode 100644 src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupController.kt create mode 100644 src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupTokenController.kt rename src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/{AccountControllers.kt => account/UserController.kt} (53%) rename src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/{ => account}/GroupService.kt (84%) create mode 100644 src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/account/GroupTokenService.kt rename src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/{ => account}/UserService.kt (93%) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/Constants.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/Constants.kt index d7853b7..73fe84c 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/Constants.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/Constants.kt @@ -3,15 +3,16 @@ package dev.fyloz.colorrecipesexplorer object Constants { object ControllerPaths { const val COMPANY = "/api/company" + const val GROUP_TOKEN = "/api/account/group/token" const val FILE = "/api/file" - const val GROUP = "/api/user/group" + const val GROUP = "/api/account/group" const val INVENTORY = "/api/inventory" const val MATERIAL = "/api/material" const val MATERIAL_TYPE = "/api/materialtype" const val MIX = "/api/recipe/mix" const val RECIPE = "/api/recipe" const val TOUCH_UP_KIT = "/api/touchupkit" - const val USER = "/api/user" + const val USER = "/api/account/user" } object FilePaths { @@ -25,6 +26,7 @@ object Constants { object ModelNames { const val COMPANY = "Company" + const val GROUP_TOKEN = "GroupToken" const val GROUP = "Group" const val MATERIAL = "Material" const val MATERIAL_TYPE = "MaterialType" @@ -47,4 +49,4 @@ object Constants { object ValidationRegexes { const val VALIDATION_COLOR_PATTERN = "^#([0-9a-f]{6})$" } -} \ No newline at end of file +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/annotations/PermissionAnnotations.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/annotations/PermissionAnnotations.kt index 8f78572..71fdd01 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/annotations/PermissionAnnotations.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/annotations/PermissionAnnotations.kt @@ -2,6 +2,12 @@ package dev.fyloz.colorrecipesexplorer.config.annotations import org.springframework.security.access.prepost.PreAuthorize +@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +@MustBeDocumented +@PreAuthorize("hasAuthority('ADMIN')") +annotation class PreAuthorizeAdmin + @Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS) @Retention(AnnotationRetention.RUNTIME) @MustBeDocumented diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/JwtFilters.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/JwtFilters.kt index 47f997f..b7b5f48 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/JwtFilters.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/JwtFilters.kt @@ -2,12 +2,12 @@ package dev.fyloz.colorrecipesexplorer.config.security import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties -import dev.fyloz.colorrecipesexplorer.dtos.UserDetails -import dev.fyloz.colorrecipesexplorer.dtos.UserDto -import dev.fyloz.colorrecipesexplorer.dtos.UserLoginRequestDto +import dev.fyloz.colorrecipesexplorer.dtos.account.UserDetails +import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto +import dev.fyloz.colorrecipesexplorer.dtos.account.UserLoginRequestDto import dev.fyloz.colorrecipesexplorer.exception.NotFoundException -import dev.fyloz.colorrecipesexplorer.logic.users.JwtLogic -import dev.fyloz.colorrecipesexplorer.logic.users.UserDetailsLogic +import dev.fyloz.colorrecipesexplorer.logic.account.JwtLogic +import dev.fyloz.colorrecipesexplorer.logic.account.UserDetailsLogic import dev.fyloz.colorrecipesexplorer.utils.addCookie import io.jsonwebtoken.ExpiredJwtException import org.springframework.security.authentication.AuthenticationManager diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt index c17ee93..188e57b 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt @@ -1,11 +1,11 @@ package dev.fyloz.colorrecipesexplorer.config.security import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties -import dev.fyloz.colorrecipesexplorer.dtos.UserDto +import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto import dev.fyloz.colorrecipesexplorer.emergencyMode -import dev.fyloz.colorrecipesexplorer.logic.users.JwtLogic -import dev.fyloz.colorrecipesexplorer.logic.users.UserDetailsLogic -import dev.fyloz.colorrecipesexplorer.logic.users.UserLogic +import dev.fyloz.colorrecipesexplorer.logic.account.JwtLogic +import dev.fyloz.colorrecipesexplorer.logic.account.UserDetailsLogic +import dev.fyloz.colorrecipesexplorer.logic.account.UserLogic import dev.fyloz.colorrecipesexplorer.model.account.Permission import mu.KotlinLogging import org.slf4j.Logger diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/RecipeDto.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/RecipeDto.kt index 0a76be4..5a9d8d4 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/RecipeDto.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/RecipeDto.kt @@ -2,6 +2,7 @@ package dev.fyloz.colorrecipesexplorer.dtos import com.fasterxml.jackson.annotation.JsonIgnore import dev.fyloz.colorrecipesexplorer.Constants +import dev.fyloz.colorrecipesexplorer.dtos.account.GroupDto import java.time.LocalDate import javax.validation.constraints.Max import javax.validation.constraints.Min @@ -118,4 +119,4 @@ data class RecipePublicDataDto( val notes: List, val mixesLocation: List -) \ No newline at end of file +) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/GroupDto.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/GroupDto.kt similarity index 85% rename from src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/GroupDto.kt rename to src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/GroupDto.kt index 78ab227..8e5a61c 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/GroupDto.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/GroupDto.kt @@ -1,6 +1,7 @@ -package dev.fyloz.colorrecipesexplorer.dtos +package dev.fyloz.colorrecipesexplorer.dtos.account import com.fasterxml.jackson.annotation.JsonIgnore +import dev.fyloz.colorrecipesexplorer.dtos.EntityDto import dev.fyloz.colorrecipesexplorer.model.account.Permission import javax.validation.constraints.NotBlank import javax.validation.constraints.NotEmpty diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/GroupTokenDto.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/GroupTokenDto.kt new file mode 100644 index 0000000..8708ed2 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/GroupTokenDto.kt @@ -0,0 +1,21 @@ +package dev.fyloz.colorrecipesexplorer.dtos.account + +import java.util.UUID +import javax.validation.constraints.NotBlank + +data class GroupTokenDto( + val id: UUID, + + val name: String, + + val isValid: Boolean, + + val group: GroupDto +) + +data class GroupTokenSaveDto( + @field:NotBlank + val name: String, + + val groupId: Long +) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/UserDto.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/UserDto.kt similarity index 93% rename from src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/UserDto.kt rename to src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/UserDto.kt index edfaef4..32f2a5f 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/UserDto.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/UserDto.kt @@ -1,8 +1,9 @@ -package dev.fyloz.colorrecipesexplorer.dtos +package dev.fyloz.colorrecipesexplorer.dtos.account import com.fasterxml.jackson.annotation.JsonIgnore import dev.fyloz.colorrecipesexplorer.Constants import dev.fyloz.colorrecipesexplorer.SpringUserDetails +import dev.fyloz.colorrecipesexplorer.dtos.EntityDto import dev.fyloz.colorrecipesexplorer.model.account.Permission import dev.fyloz.colorrecipesexplorer.model.account.toAuthority import java.time.LocalDateTime @@ -57,8 +58,6 @@ data class UserSaveDto( val permissions: List, - // TODO WN: Test if working - // @JsonProperty(access = JsonProperty.Access.READ_ONLY) @field:JsonIgnore val isSystemUser: Boolean = false, @@ -91,4 +90,4 @@ class UserDetails(val user: UserDto) : SpringUserDetails { override fun isAccountNonLocked() = true override fun isCredentialsNonExpired() = true override fun isEnabled() = true -} \ No newline at end of file +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/RecipeLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/RecipeLogic.kt index 2687249..e444250 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/RecipeLogic.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/RecipeLogic.kt @@ -5,7 +5,7 @@ import dev.fyloz.colorrecipesexplorer.config.annotations.LogicComponent import dev.fyloz.colorrecipesexplorer.dtos.* import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException import dev.fyloz.colorrecipesexplorer.logic.files.WriteableFileLogic -import dev.fyloz.colorrecipesexplorer.logic.users.GroupLogic +import dev.fyloz.colorrecipesexplorer.logic.account.GroupLogic import dev.fyloz.colorrecipesexplorer.service.RecipeService import dev.fyloz.colorrecipesexplorer.utils.collections.LazyMapList import dev.fyloz.colorrecipesexplorer.utils.merge diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/RecipeStepLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/RecipeStepLogic.kt index 4aaf79f..eb24212 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/RecipeStepLogic.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/RecipeStepLogic.kt @@ -2,13 +2,12 @@ package dev.fyloz.colorrecipesexplorer.logic import dev.fyloz.colorrecipesexplorer.Constants import dev.fyloz.colorrecipesexplorer.config.annotations.LogicComponent -import dev.fyloz.colorrecipesexplorer.dtos.GroupDto +import dev.fyloz.colorrecipesexplorer.dtos.account.GroupDto import dev.fyloz.colorrecipesexplorer.dtos.RecipeGroupInformationDto import dev.fyloz.colorrecipesexplorer.dtos.RecipeStepDto import dev.fyloz.colorrecipesexplorer.exception.InvalidPositionError import dev.fyloz.colorrecipesexplorer.exception.InvalidPositionsException import dev.fyloz.colorrecipesexplorer.exception.RestException -import dev.fyloz.colorrecipesexplorer.model.account.Group import dev.fyloz.colorrecipesexplorer.service.RecipeStepService import dev.fyloz.colorrecipesexplorer.utils.PositionUtils import org.springframework.http.HttpStatus @@ -46,4 +45,4 @@ class InvalidGroupStepsPositionsException( ) { val errors: Set get() = exception.errors -} \ No newline at end of file +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/users/GroupLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupLogic.kt similarity index 92% rename from src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/users/GroupLogic.kt rename to src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupLogic.kt index b421223..1a734df 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/users/GroupLogic.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupLogic.kt @@ -1,14 +1,14 @@ -package dev.fyloz.colorrecipesexplorer.logic.users +package dev.fyloz.colorrecipesexplorer.logic.account import dev.fyloz.colorrecipesexplorer.Constants import dev.fyloz.colorrecipesexplorer.config.annotations.LogicComponent import dev.fyloz.colorrecipesexplorer.config.security.defaultGroupCookieName -import dev.fyloz.colorrecipesexplorer.dtos.GroupDto -import dev.fyloz.colorrecipesexplorer.dtos.UserDto +import dev.fyloz.colorrecipesexplorer.dtos.account.GroupDto +import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto import dev.fyloz.colorrecipesexplorer.exception.NoDefaultGroupException import dev.fyloz.colorrecipesexplorer.logic.BaseLogic import dev.fyloz.colorrecipesexplorer.logic.Logic -import dev.fyloz.colorrecipesexplorer.service.GroupService +import dev.fyloz.colorrecipesexplorer.service.account.GroupService import org.springframework.transaction.annotation.Transactional import org.springframework.web.util.WebUtils import javax.servlet.http.HttpServletRequest @@ -77,4 +77,4 @@ class DefaultGroupLogic(service: GroupService, private val userLogic: UserLogic) throw alreadyExistsException(value = name) } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupTokenLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupTokenLogic.kt new file mode 100644 index 0000000..12211ca --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupTokenLogic.kt @@ -0,0 +1,85 @@ +package dev.fyloz.colorrecipesexplorer.logic.account + +import dev.fyloz.colorrecipesexplorer.Constants +import dev.fyloz.colorrecipesexplorer.config.annotations.LogicComponent +import dev.fyloz.colorrecipesexplorer.dtos.account.GroupTokenDto +import dev.fyloz.colorrecipesexplorer.dtos.account.GroupTokenSaveDto +import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException +import dev.fyloz.colorrecipesexplorer.exception.NotFoundException +import dev.fyloz.colorrecipesexplorer.logic.BaseLogic +import dev.fyloz.colorrecipesexplorer.service.account.GroupTokenService +import java.util.UUID + +interface GroupTokenLogic { + fun getAll(): Collection + fun getById(id: String): GroupTokenDto + fun save(dto: GroupTokenSaveDto): GroupTokenDto + fun deleteById(id: String) +} + +@LogicComponent +class DefaultGroupTokenLogic(private val service: GroupTokenService, private val groupLogic: GroupLogic) : + GroupTokenLogic { + private val typeName = Constants.ModelNames.GROUP_TOKEN + private val typeNameLowerCase = typeName.lowercase() + + override fun getAll() = service.getAll() + + override fun getById(id: String) = + service.getById(UUID.fromString(id)) ?: throw notFoundException(value = id) + + override fun save(dto: GroupTokenSaveDto): GroupTokenDto { + throwIfNameAlreadyExists(dto.name) + + val token = GroupTokenDto( + generateUniqueUUIDForName(dto.name), + dto.name, + true, + groupLogic.getById(dto.groupId) + ) + + return service.save(token) + } + + override fun deleteById(id: String) { + service.deleteById(UUID.fromString(id)) + } + + private fun generateUniqueUUIDForName(name: String): UUID { + var id = generateUUIDForName(name) + + // UUIDs do not guarantee that collisions can't happen + while (service.existsById(id)) { + id = generateUUIDForName(name) + } + + return id + } + + private fun generateUUIDForName(name: String) = + UUID.nameUUIDFromBytes(name.toByteArray()) + + private fun throwIfNameAlreadyExists(name: String) { + if (service.existsByName(name)) { + throw alreadyExistsException(value = name) + } + } + + private fun notFoundException(identifierName: String = BaseLogic.ID_IDENTIFIER_NAME, value: Any) = + NotFoundException( + typeNameLowerCase, + "$typeName not found", + "A $typeNameLowerCase with the $identifierName '$value' could not be found", + value, + identifierName + ) + + private fun alreadyExistsException(identifierName: String = BaseLogic.NAME_IDENTIFIER_NAME, value: Any) = + AlreadyExistsException( + typeNameLowerCase, + "$typeName already exists", + "A $typeNameLowerCase with the $identifierName '$value' already exists", + value, + identifierName + ) +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/users/JwtLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/JwtLogic.kt similarity index 92% rename from src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/users/JwtLogic.kt rename to src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/JwtLogic.kt index 47469bf..3efbf51 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/users/JwtLogic.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/JwtLogic.kt @@ -1,10 +1,10 @@ -package dev.fyloz.colorrecipesexplorer.logic.users +package dev.fyloz.colorrecipesexplorer.logic.account import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.readValue import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties -import dev.fyloz.colorrecipesexplorer.dtos.UserDetails -import dev.fyloz.colorrecipesexplorer.dtos.UserDto +import dev.fyloz.colorrecipesexplorer.dtos.account.UserDetails +import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto import dev.fyloz.colorrecipesexplorer.utils.base64encode import dev.fyloz.colorrecipesexplorer.utils.toDate import io.jsonwebtoken.Jwts diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/users/UserDetailsLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserDetailsLogic.kt similarity index 93% rename from src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/users/UserDetailsLogic.kt rename to src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserDetailsLogic.kt index 25b8369..8a621ab 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/users/UserDetailsLogic.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserDetailsLogic.kt @@ -1,11 +1,11 @@ -package dev.fyloz.colorrecipesexplorer.logic.users +package dev.fyloz.colorrecipesexplorer.logic.account import dev.fyloz.colorrecipesexplorer.SpringUserDetails import dev.fyloz.colorrecipesexplorer.SpringUserDetailsService import dev.fyloz.colorrecipesexplorer.config.annotations.RequireDatabase import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties -import dev.fyloz.colorrecipesexplorer.dtos.UserDetails -import dev.fyloz.colorrecipesexplorer.dtos.UserDto +import dev.fyloz.colorrecipesexplorer.dtos.account.UserDetails +import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto import dev.fyloz.colorrecipesexplorer.exception.NotFoundException import dev.fyloz.colorrecipesexplorer.model.account.Permission import dev.fyloz.colorrecipesexplorer.model.account.User diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/users/UserLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserLogic.kt similarity index 94% rename from src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/users/UserLogic.kt rename to src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserLogic.kt index bd17f04..49d3f5d 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/users/UserLogic.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserLogic.kt @@ -1,20 +1,19 @@ -package dev.fyloz.colorrecipesexplorer.logic.users +package dev.fyloz.colorrecipesexplorer.logic.account import dev.fyloz.colorrecipesexplorer.Constants import dev.fyloz.colorrecipesexplorer.config.annotations.LogicComponent import dev.fyloz.colorrecipesexplorer.config.security.authorizationCookieName import dev.fyloz.colorrecipesexplorer.config.security.blacklistedJwtTokens -import dev.fyloz.colorrecipesexplorer.dtos.GroupDto -import dev.fyloz.colorrecipesexplorer.dtos.UserDto -import dev.fyloz.colorrecipesexplorer.dtos.UserSaveDto -import dev.fyloz.colorrecipesexplorer.dtos.UserUpdateDto +import dev.fyloz.colorrecipesexplorer.dtos.account.GroupDto +import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto +import dev.fyloz.colorrecipesexplorer.dtos.account.UserSaveDto +import dev.fyloz.colorrecipesexplorer.dtos.account.UserUpdateDto import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException import dev.fyloz.colorrecipesexplorer.logic.BaseLogic import dev.fyloz.colorrecipesexplorer.logic.Logic import dev.fyloz.colorrecipesexplorer.model.account.Permission -import dev.fyloz.colorrecipesexplorer.service.UserService +import dev.fyloz.colorrecipesexplorer.service.account.UserService import org.springframework.context.annotation.Lazy -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.web.util.WebUtils import java.time.LocalDateTime @@ -166,4 +165,4 @@ class DefaultUserLogic( ) } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/account/GroupToken.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/account/GroupToken.kt new file mode 100644 index 0000000..0820672 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/account/GroupToken.kt @@ -0,0 +1,26 @@ +package dev.fyloz.colorrecipesexplorer.model.account + +import java.util.UUID +import javax.persistence.Column +import javax.persistence.Entity +import javax.persistence.Id +import javax.persistence.JoinColumn +import javax.persistence.ManyToOne +import javax.persistence.Table + +@Entity +@Table(name = "group_token") +data class GroupToken( + @Id + val id: UUID, + + @Column(unique = true) + val name: String, + + @Column(name = "is_valid") + val isValid: Boolean, + + @ManyToOne + @JoinColumn(name = "group_id") + val group: Group +) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/AccountRepository.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/AccountRepository.kt index 82575bd..31e8d9f 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/AccountRepository.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/AccountRepository.kt @@ -1,10 +1,12 @@ package dev.fyloz.colorrecipesexplorer.repository +import dev.fyloz.colorrecipesexplorer.model.account.GroupToken import dev.fyloz.colorrecipesexplorer.model.account.Group import dev.fyloz.colorrecipesexplorer.model.account.User import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query import org.springframework.stereotype.Repository +import java.util.UUID @Repository interface UserRepository : JpaRepository { @@ -28,3 +30,9 @@ interface GroupRepository : JpaRepository { /** Checks if a group with the given [name] and a different [id] exists. */ fun existsByNameAndIdNot(name: String, id: Long): Boolean } + +@Repository +interface GroupTokenRepository : JpaRepository { + /** Checks if a token with the given [name] exists. */ + fun existsByName(name: String): Boolean +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupController.kt new file mode 100644 index 0000000..ac3971c --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupController.kt @@ -0,0 +1,82 @@ +package dev.fyloz.colorrecipesexplorer.rest.account + +import dev.fyloz.colorrecipesexplorer.Constants +import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeEditUsers +import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeViewUsers +import dev.fyloz.colorrecipesexplorer.dtos.account.GroupDto +import dev.fyloz.colorrecipesexplorer.logic.account.GroupLogic +import dev.fyloz.colorrecipesexplorer.logic.account.UserLogic +import dev.fyloz.colorrecipesexplorer.rest.created +import dev.fyloz.colorrecipesexplorer.rest.noContent +import dev.fyloz.colorrecipesexplorer.rest.ok +import org.springframework.context.annotation.Profile +import org.springframework.security.access.prepost.PreAuthorize +import org.springframework.web.bind.annotation.* +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse +import javax.validation.Valid + + +@RestController +@RequestMapping(Constants.ControllerPaths.GROUP) +@Profile("!emergency") +class GroupController( + private val groupLogic: GroupLogic, + private val userLogic: UserLogic +) { + @GetMapping + @PreAuthorize("hasAnyAuthority('VIEW_RECIPES', 'VIEW_USERS')") + fun getAll() = + ok(groupLogic.getAll()) + + @GetMapping("{id}") + @PreAuthorizeViewUsers + fun getById(@PathVariable id: Long) = + ok(groupLogic.getById(id)) + + @GetMapping("{id}/users") + @PreAuthorizeViewUsers + fun getUsersForGroup(@PathVariable id: Long) = + ok(groupLogic.getUsersForGroup(id)) + + @PostMapping("default/{groupId}") + @PreAuthorizeViewUsers + fun setDefaultGroup(@PathVariable groupId: Long, response: HttpServletResponse) = + noContent { + groupLogic.setResponseDefaultGroup(groupId, response) + } + + @GetMapping("default") + @PreAuthorizeViewUsers + fun getRequestDefaultGroup(request: HttpServletRequest) = + ok(with(groupLogic) { + getRequestDefaultGroup(request) + }) + + @GetMapping("currentuser") + fun getCurrentGroupUser(request: HttpServletRequest) = + ok(with(groupLogic.getRequestDefaultGroup(request)) { + userLogic.getDefaultGroupUser(this) + }) + + @PostMapping + @PreAuthorizeEditUsers + fun save(@Valid @RequestBody group: GroupDto) = + created(Constants.ControllerPaths.GROUP) { + groupLogic.save(group) + } + + @PutMapping + @PreAuthorizeEditUsers + fun update(@Valid @RequestBody group: GroupDto) = + noContent { + groupLogic.update(group) + } + + @DeleteMapping("{id}") + @PreAuthorizeEditUsers + fun deleteById(@PathVariable id: Long) = + noContent { + groupLogic.deleteById(id) + } +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupTokenController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupTokenController.kt new file mode 100644 index 0000000..e2b24aa --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupTokenController.kt @@ -0,0 +1,40 @@ +package dev.fyloz.colorrecipesexplorer.rest.account + +import dev.fyloz.colorrecipesexplorer.Constants +import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeAdmin +import dev.fyloz.colorrecipesexplorer.dtos.account.GroupTokenSaveDto +import dev.fyloz.colorrecipesexplorer.logic.account.GroupTokenLogic +import dev.fyloz.colorrecipesexplorer.rest.created +import dev.fyloz.colorrecipesexplorer.rest.noContent +import dev.fyloz.colorrecipesexplorer.rest.ok +import org.springframework.context.annotation.Profile +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import javax.validation.Valid + +@RestController +@RequestMapping(Constants.ControllerPaths.GROUP_TOKEN) +@PreAuthorizeAdmin +@Profile("!emergency") +class GroupTokenController(private val groupTokenLogic: GroupTokenLogic) { + @GetMapping + fun getAll() = ok(groupTokenLogic.getAll()) + + @GetMapping("{id}") + fun getById(@PathVariable id: String) = ok(groupTokenLogic.getById(id)) + + @PostMapping + fun save(@RequestBody @Valid dto: GroupTokenSaveDto) = with(groupTokenLogic.save(dto)) { + created(Constants.ControllerPaths.GROUP_TOKEN, this, this.id) + } + + @DeleteMapping("{id}") + fun deleteById(@PathVariable id: String) = noContent { + groupTokenLogic.deleteById(id) + } +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/AccountControllers.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/UserController.kt similarity index 53% rename from src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/AccountControllers.kt rename to src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/UserController.kt index dd423b4..d611e82 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/AccountControllers.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/UserController.kt @@ -1,21 +1,21 @@ -package dev.fyloz.colorrecipesexplorer.rest +package dev.fyloz.colorrecipesexplorer.rest.account import dev.fyloz.colorrecipesexplorer.Constants import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeEditUsers import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeViewUsers -import dev.fyloz.colorrecipesexplorer.dtos.GroupDto -import dev.fyloz.colorrecipesexplorer.dtos.UserDto -import dev.fyloz.colorrecipesexplorer.dtos.UserSaveDto -import dev.fyloz.colorrecipesexplorer.dtos.UserUpdateDto -import dev.fyloz.colorrecipesexplorer.logic.users.GroupLogic -import dev.fyloz.colorrecipesexplorer.logic.users.UserLogic +import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto +import dev.fyloz.colorrecipesexplorer.dtos.account.UserSaveDto +import dev.fyloz.colorrecipesexplorer.dtos.account.UserUpdateDto +import dev.fyloz.colorrecipesexplorer.logic.account.UserLogic import dev.fyloz.colorrecipesexplorer.model.account.Permission +import dev.fyloz.colorrecipesexplorer.rest.created +import dev.fyloz.colorrecipesexplorer.rest.noContent +import dev.fyloz.colorrecipesexplorer.rest.ok import org.springframework.context.annotation.Profile import org.springframework.http.MediaType import org.springframework.security.access.prepost.PreAuthorize import org.springframework.web.bind.annotation.* import javax.servlet.http.HttpServletRequest -import javax.servlet.http.HttpServletResponse import javax.validation.Valid @RestController @@ -77,70 +77,6 @@ class UserController(private val userLogic: UserLogic) { userLogic.deleteById(id) } -@RestController -@RequestMapping(Constants.ControllerPaths.GROUP) -@Profile("!emergency") -class GroupsController( - private val groupLogic: GroupLogic, - private val userLogic: UserLogic -) { - @GetMapping - @PreAuthorize("hasAnyAuthority('VIEW_RECIPES', 'VIEW_USERS')") - fun getAll() = - ok(groupLogic.getAll()) - - @GetMapping("{id}") - @PreAuthorizeViewUsers - fun getById(@PathVariable id: Long) = - ok(groupLogic.getById(id)) - - @GetMapping("{id}/users") - @PreAuthorizeViewUsers - fun getUsersForGroup(@PathVariable id: Long) = - ok(groupLogic.getUsersForGroup(id)) - - @PostMapping("default/{groupId}") - @PreAuthorizeViewUsers - fun setDefaultGroup(@PathVariable groupId: Long, response: HttpServletResponse) = - noContent { - groupLogic.setResponseDefaultGroup(groupId, response) - } - - @GetMapping("default") - @PreAuthorizeViewUsers - fun getRequestDefaultGroup(request: HttpServletRequest) = - ok(with(groupLogic) { - getRequestDefaultGroup(request) - }) - - @GetMapping("currentuser") - fun getCurrentGroupUser(request: HttpServletRequest) = - ok(with(groupLogic.getRequestDefaultGroup(request)) { - userLogic.getDefaultGroupUser(this) - }) - - @PostMapping - @PreAuthorizeEditUsers - fun save(@Valid @RequestBody group: GroupDto) = - created(Constants.ControllerPaths.GROUP) { - groupLogic.save(group) - } - - @PutMapping - @PreAuthorizeEditUsers - fun update(@Valid @RequestBody group: GroupDto) = - noContent { - groupLogic.update(group) - } - - @DeleteMapping("{id}") - @PreAuthorizeEditUsers - fun deleteById(@PathVariable id: Long) = - noContent { - groupLogic.deleteById(id) - } -} - @RestController @RequestMapping("api") @Profile("!emergency") diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeService.kt index 8532a02..e0edbf0 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeService.kt @@ -8,6 +8,7 @@ import dev.fyloz.colorrecipesexplorer.model.ConfigurationType import dev.fyloz.colorrecipesexplorer.model.Recipe import dev.fyloz.colorrecipesexplorer.model.RecipeGroupInformation import dev.fyloz.colorrecipesexplorer.repository.RecipeRepository +import dev.fyloz.colorrecipesexplorer.service.account.GroupService import dev.fyloz.colorrecipesexplorer.utils.collections.lazyMap import org.springframework.transaction.annotation.Transactional import java.time.LocalDate @@ -89,4 +90,4 @@ class DefaultRecipeService( with(Period.parse(configLogic.getContent(ConfigurationType.RECIPE_APPROBATION_EXPIRATION))) { recipe.approbationDate?.plus(this)?.isBefore(LocalDate.now()) } -} \ No newline at end of file +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/GroupService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/account/GroupService.kt similarity index 84% rename from src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/GroupService.kt rename to src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/account/GroupService.kt index 1b13ced..5772c5f 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/GroupService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/account/GroupService.kt @@ -1,11 +1,13 @@ -package dev.fyloz.colorrecipesexplorer.service +package dev.fyloz.colorrecipesexplorer.service.account import dev.fyloz.colorrecipesexplorer.config.annotations.ServiceComponent -import dev.fyloz.colorrecipesexplorer.dtos.GroupDto +import dev.fyloz.colorrecipesexplorer.dtos.account.GroupDto import dev.fyloz.colorrecipesexplorer.model.account.Group import dev.fyloz.colorrecipesexplorer.model.account.Permission import dev.fyloz.colorrecipesexplorer.model.account.flat import dev.fyloz.colorrecipesexplorer.repository.GroupRepository +import dev.fyloz.colorrecipesexplorer.service.BaseService +import dev.fyloz.colorrecipesexplorer.service.Service interface GroupService : Service { /** Checks if a group with the given [name] and a different [id] exists. */ @@ -28,4 +30,4 @@ class DefaultGroupService(repository: GroupRepository) : BaseService + fun getById(id: UUID): GroupTokenDto? + fun save(token: GroupTokenDto): GroupTokenDto + fun deleteById(id: UUID) + + fun toDto(entity: GroupToken): GroupTokenDto + fun toEntity(dto: GroupTokenDto): GroupToken +} + +@ServiceComponent +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 getAll() = repository.findAll().map(::toDto) + + override fun getById(id: UUID): GroupTokenDto? { + val entity = repository.findByIdOrNull(id) + + return if (entity != null) toDto(entity) else null + } + + override fun save(token: GroupTokenDto): GroupTokenDto { + val entity = repository.save(toEntity(token)) + return toDto(entity) + } + + override fun deleteById(id: UUID) = repository.deleteById(id) + + override fun toDto(entity: GroupToken) = + GroupTokenDto(entity.id, entity.name, entity.isValid, groupService.toDto(entity.group)) + + override fun toEntity(dto: GroupTokenDto) = + GroupToken(dto.id, dto.name, dto.isValid, groupService.toEntity(dto.group)) +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/UserService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/account/UserService.kt similarity index 93% rename from src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/UserService.kt rename to src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/account/UserService.kt index 4792397..d942ab7 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/UserService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/account/UserService.kt @@ -1,12 +1,14 @@ -package dev.fyloz.colorrecipesexplorer.service +package dev.fyloz.colorrecipesexplorer.service.account import dev.fyloz.colorrecipesexplorer.config.annotations.ServiceComponent -import dev.fyloz.colorrecipesexplorer.dtos.GroupDto -import dev.fyloz.colorrecipesexplorer.dtos.UserDto +import dev.fyloz.colorrecipesexplorer.dtos.account.GroupDto +import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto import dev.fyloz.colorrecipesexplorer.model.account.Permission import dev.fyloz.colorrecipesexplorer.model.account.User import dev.fyloz.colorrecipesexplorer.model.account.flat import dev.fyloz.colorrecipesexplorer.repository.UserRepository +import dev.fyloz.colorrecipesexplorer.service.BaseService +import dev.fyloz.colorrecipesexplorer.service.Service import org.springframework.data.repository.findByIdOrNull interface UserService : Service { @@ -100,4 +102,4 @@ class DefaultUserService(repository: UserRepository, private val groupService: G return perms } -} \ No newline at end of file +} diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultJwtLogicTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultJwtLogicTest.kt index e5ddab7..1e91082 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultJwtLogicTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultJwtLogicTest.kt @@ -3,10 +3,10 @@ package dev.fyloz.colorrecipesexplorer.logic import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties -import dev.fyloz.colorrecipesexplorer.dtos.UserDetails -import dev.fyloz.colorrecipesexplorer.dtos.UserDto -import dev.fyloz.colorrecipesexplorer.logic.users.DefaultJwtLogic -import dev.fyloz.colorrecipesexplorer.logic.users.jwtClaimUser +import dev.fyloz.colorrecipesexplorer.dtos.account.UserDetails +import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto +import dev.fyloz.colorrecipesexplorer.logic.account.DefaultJwtLogic +import dev.fyloz.colorrecipesexplorer.logic.account.jwtClaimUser import dev.fyloz.colorrecipesexplorer.utils.base64encode import dev.fyloz.colorrecipesexplorer.utils.isAround import io.jsonwebtoken.Jwts diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultRecipeLogicTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultRecipeLogicTest.kt index 101da52..47f5ee1 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultRecipeLogicTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultRecipeLogicTest.kt @@ -2,7 +2,7 @@ package dev.fyloz.colorrecipesexplorer.logic import dev.fyloz.colorrecipesexplorer.dtos.* import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException -import dev.fyloz.colorrecipesexplorer.logic.users.GroupLogic +import dev.fyloz.colorrecipesexplorer.logic.account.GroupLogic import dev.fyloz.colorrecipesexplorer.service.RecipeService import io.mockk.* import org.junit.jupiter.api.AfterEach @@ -213,4 +213,4 @@ class DefaultRecipeLogicTest { mixLogicMock.updateLocations(any()) } } -} \ No newline at end of file +} diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultRecipeStepLogicTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultRecipeStepLogicTest.kt index 23aac43..ba93fe6 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultRecipeStepLogicTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultRecipeStepLogicTest.kt @@ -1,6 +1,6 @@ package dev.fyloz.colorrecipesexplorer.logic -import dev.fyloz.colorrecipesexplorer.dtos.GroupDto +import dev.fyloz.colorrecipesexplorer.dtos.account.GroupDto import dev.fyloz.colorrecipesexplorer.dtos.RecipeGroupInformationDto import dev.fyloz.colorrecipesexplorer.dtos.RecipeStepDto import dev.fyloz.colorrecipesexplorer.exception.InvalidPositionError @@ -57,4 +57,4 @@ class DefaultRecipeStepLogicTest { // Assert assertThrows { recipeStepLogic.validateGroupInformationSteps(groupInfo) } } -} \ No newline at end of file +} diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultGroupLogicTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultGroupLogicTest.kt index 1f208b1..fa5d8bf 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultGroupLogicTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultGroupLogicTest.kt @@ -1,11 +1,9 @@ package dev.fyloz.colorrecipesexplorer.logic.account -import dev.fyloz.colorrecipesexplorer.dtos.GroupDto -import dev.fyloz.colorrecipesexplorer.dtos.UserDto +import dev.fyloz.colorrecipesexplorer.dtos.account.GroupDto +import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException -import dev.fyloz.colorrecipesexplorer.logic.users.DefaultGroupLogic -import dev.fyloz.colorrecipesexplorer.logic.users.UserLogic -import dev.fyloz.colorrecipesexplorer.service.GroupService +import dev.fyloz.colorrecipesexplorer.service.account.GroupService import io.mockk.* import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test @@ -84,4 +82,4 @@ class DefaultGroupLogicTest { userLogicMock.deleteById(group.defaultGroupUserId) } } -} \ No newline at end of file +} diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultUserLogicTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultUserLogicTest.kt index d4f8b32..d288021 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultUserLogicTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultUserLogicTest.kt @@ -1,15 +1,13 @@ package dev.fyloz.colorrecipesexplorer.logic.account -import dev.fyloz.colorrecipesexplorer.dtos.GroupDto -import dev.fyloz.colorrecipesexplorer.dtos.UserDto -import dev.fyloz.colorrecipesexplorer.dtos.UserSaveDto -import dev.fyloz.colorrecipesexplorer.dtos.UserUpdateDto +import dev.fyloz.colorrecipesexplorer.dtos.account.GroupDto +import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto +import dev.fyloz.colorrecipesexplorer.dtos.account.UserSaveDto +import dev.fyloz.colorrecipesexplorer.dtos.account.UserUpdateDto import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException import dev.fyloz.colorrecipesexplorer.exception.NotFoundException -import dev.fyloz.colorrecipesexplorer.logic.users.DefaultUserLogic -import dev.fyloz.colorrecipesexplorer.logic.users.GroupLogic import dev.fyloz.colorrecipesexplorer.model.account.Permission -import dev.fyloz.colorrecipesexplorer.service.UserService +import dev.fyloz.colorrecipesexplorer.service.account.UserService import io.mockk.* import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test @@ -303,4 +301,4 @@ class DefaultUserLogicTest { userLogic.update(user) } } -} \ No newline at end of file +} From 37b597936b92c1541ead6a6f56c75d64b10ee158 Mon Sep 17 00:00:00 2001 From: FyloZ Date: Thu, 28 Apr 2022 15:02:01 -0400 Subject: [PATCH 02/10] #30 Add login from group tokens --- .../fyloz/colorrecipesexplorer/Constants.kt | 39 +++++++---- .../security/GroupAuthenticationToken.kt | 13 ++++ .../GroupTokenAuthenticationProvider.kt | 37 +++++++++++ .../config/security/SecurityConfig.kt | 50 ++++++++++---- .../filters/GroupTokenAuthenticationFilter.kt | 35 ++++++++++ .../filters/JwtAuthenticationFilter.kt | 63 ++++++++++++++++++ .../JwtAuthorizationFilter.kt} | 66 ++----------------- .../UsernamePasswordAuthenticationFilter.kt | 39 +++++++++++ .../dtos/account/UserDto.kt | 25 ++++--- .../logic/account/GroupLogic.kt | 5 +- .../logic/account/GroupTokenLogic.kt | 7 +- .../logic/account/JwtLogic.kt | 19 ++---- .../logic/account/UserLogic.kt | 5 +- .../rest/account/GroupTokenController.kt | 42 +++++++++--- .../fyloz/colorrecipesexplorer/utils/Http.kt | 32 +++++---- src/main/resources/application.properties | 2 + .../logic/DefaultRecipeLogicTest.kt | 1 + 17 files changed, 345 insertions(+), 135 deletions(-) create mode 100644 src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupAuthenticationToken.kt create mode 100644 src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupTokenAuthenticationProvider.kt create mode 100644 src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/GroupTokenAuthenticationFilter.kt create mode 100644 src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthenticationFilter.kt rename src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/{JwtFilters.kt => filters/JwtAuthorizationFilter.kt} (55%) create mode 100644 src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/UsernamePasswordAuthenticationFilter.kt diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/Constants.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/Constants.kt index 73fe84c..ff032b5 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/Constants.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/Constants.kt @@ -1,18 +1,30 @@ package dev.fyloz.colorrecipesexplorer object Constants { + var DEBUG_MODE = false // Not really a constant, but should never change after the app startup + object ControllerPaths { - const val COMPANY = "/api/company" - const val GROUP_TOKEN = "/api/account/group/token" - const val FILE = "/api/file" - const val GROUP = "/api/account/group" - const val INVENTORY = "/api/inventory" - const val MATERIAL = "/api/material" - const val MATERIAL_TYPE = "/api/materialtype" - const val MIX = "/api/recipe/mix" - const val RECIPE = "/api/recipe" - const val TOUCH_UP_KIT = "/api/touchupkit" - const val USER = "/api/account/user" + const val BASE_PATH = "/api" + + const val COMPANY = "$BASE_PATH/company" + const val GROUP_TOKEN = "$BASE_PATH/account/group/token" + const val FILE = "$BASE_PATH/file" + const val GROUP = "$BASE_PATH/account/group" + const val INVENTORY = "$BASE_PATH/inventory" + const val MATERIAL = "$BASE_PATH/material" + const val MATERIAL_TYPE = "$BASE_PATH/materialtype" + const val MIX = "$BASE_PATH/recipe/mix" + const val RECIPE = "$BASE_PATH/recipe" + const val TOUCH_UP_KIT = "$BASE_PATH/touchupkit" + const val USER = "$BASE_PATH/account/user" + + const val LOGIN = "$BASE_PATH/account/login" + const val GROUP_LOGIN = "$BASE_PATH/account/login/group" + } + + object CookieNames { + const val AUTHORIZATION = "Authorization" + const val GROUP_TOKEN = "Group-Token" } object FilePaths { @@ -24,6 +36,11 @@ object Constants { const val RECIPE_IMAGES = "$IMAGES/recipes" } + object HeaderNames { + const val ACCESS_CONTROL_EXPOSE_HEADERS = "Access-Control-Expose-Headers" + const val AUTHORIZATION = "Authorization" + } + object ModelNames { const val COMPANY = "Company" const val GROUP_TOKEN = "GroupToken" diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupAuthenticationToken.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupAuthenticationToken.kt new file mode 100644 index 0000000..9bb42a3 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupAuthenticationToken.kt @@ -0,0 +1,13 @@ +package dev.fyloz.colorrecipesexplorer.config.security + +import org.springframework.security.authentication.AbstractAuthenticationToken +import org.springframework.security.core.GrantedAuthority +import java.util.* + +class GroupAuthenticationToken(val id: String) : AbstractAuthenticationToken(null) { + override fun getPrincipal() = id + + // There is no credential needed to log in with a group token, just use the group token id + override fun getCredentials() = id +} + diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupTokenAuthenticationProvider.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupTokenAuthenticationProvider.kt new file mode 100644 index 0000000..8150f07 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupTokenAuthenticationProvider.kt @@ -0,0 +1,37 @@ +package dev.fyloz.colorrecipesexplorer.config.security + +import dev.fyloz.colorrecipesexplorer.dtos.account.UserDetails +import dev.fyloz.colorrecipesexplorer.exception.NotFoundException +import dev.fyloz.colorrecipesexplorer.logic.account.GroupTokenLogic +import org.springframework.security.authentication.AuthenticationProvider +import org.springframework.security.authentication.BadCredentialsException +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.Authentication +import java.util.* + +class GroupTokenAuthenticationProvider(private val groupTokenLogic: GroupTokenLogic) : AuthenticationProvider { + override fun authenticate(authentication: Authentication): Authentication { + val groupAuthenticationToken = authentication as GroupAuthenticationToken + + val groupTokenId = parseGroupTokenId(groupAuthenticationToken.id) + val groupToken = retrieveGroupToken(groupTokenId) + + val userDetails = UserDetails(groupToken.id, groupToken.name, "", groupToken.group.id, groupToken.group.permissions) + return UsernamePasswordAuthenticationToken(userDetails, null, userDetails.authorities) + } + + override fun supports(authentication: Class<*>) = + authentication.isAssignableFrom(GroupAuthenticationToken::class.java) + + private fun parseGroupTokenId(id: String) = try { + UUID.fromString(id) + } catch (_: IllegalArgumentException) { + throw BadCredentialsException("Group token id must be a valid UUID") + } + + private fun retrieveGroupToken(id: UUID) = try { + groupTokenLogic.getById(id) + } catch (_: NotFoundException) { + throw BadCredentialsException("Failed to find group token with id '$id'") + } +} \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt index 188e57b..34485d3 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt @@ -1,8 +1,13 @@ package dev.fyloz.colorrecipesexplorer.config.security +import dev.fyloz.colorrecipesexplorer.Constants import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties +import dev.fyloz.colorrecipesexplorer.config.security.filters.GroupTokenAuthenticationFilter +import dev.fyloz.colorrecipesexplorer.config.security.filters.UsernamePasswordAuthenticationFilter +import dev.fyloz.colorrecipesexplorer.config.security.filters.JwtAuthorizationFilter import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto import dev.fyloz.colorrecipesexplorer.emergencyMode +import dev.fyloz.colorrecipesexplorer.logic.account.GroupTokenLogic import dev.fyloz.colorrecipesexplorer.logic.account.JwtLogic import dev.fyloz.colorrecipesexplorer.logic.account.UserDetailsLogic import dev.fyloz.colorrecipesexplorer.logic.account.UserLogic @@ -14,6 +19,7 @@ import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Lazy import org.springframework.context.annotation.Profile +import org.springframework.core.annotation.Order import org.springframework.core.env.Environment import org.springframework.http.HttpMethod import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder @@ -25,6 +31,7 @@ import org.springframework.security.config.http.SessionCreationPolicy import org.springframework.security.core.AuthenticationException import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder import org.springframework.security.web.AuthenticationEntryPoint +import org.springframework.security.web.authentication.www.BasicAuthenticationFilter import org.springframework.stereotype.Component import org.springframework.web.cors.CorsConfiguration import org.springframework.web.cors.UrlBasedCorsConfigurationSource @@ -39,13 +46,13 @@ private const val rootUserLastName = "User" abstract class BaseSecurityConfig( private val userDetailsLogic: UserDetailsLogic, private val jwtLogic: JwtLogic, + private val groupTokenLogic: GroupTokenLogic, private val environment: Environment, protected val securityProperties: CreSecurityProperties ) : WebSecurityConfigurerAdapter() { protected abstract val logger: Logger protected val passwordEncoder = BCryptPasswordEncoder() - var debugMode = false @Bean open fun passwordEncoder() = @@ -69,21 +76,30 @@ abstract class BaseSecurityConfig( } override fun configure(authBuilder: AuthenticationManagerBuilder) { - authBuilder.userDetailsService(userDetailsLogic).passwordEncoder(passwordEncoder) + authBuilder + .authenticationProvider(GroupTokenAuthenticationProvider(groupTokenLogic)) + .userDetailsService(userDetailsLogic).passwordEncoder(passwordEncoder) } override fun configure(http: HttpSecurity) { + val authManager = authenticationManager() + http .headers().frameOptions().disable() .and() .csrf().disable() - .addFilter( - JwtAuthenticationFilter( - authenticationManager(), + .addFilterBefore( + GroupTokenAuthenticationFilter(jwtLogic, securityProperties, authManager), + BasicAuthenticationFilter::class.java + ) + .addFilterBefore( + UsernamePasswordAuthenticationFilter( jwtLogic, securityProperties, + authManager, this::updateUserLoginTime - ) + ), + BasicAuthenticationFilter::class.java ) .addFilter( JwtAuthorizationFilter(jwtLogic, authenticationManager(), userDetailsLogic) @@ -91,11 +107,12 @@ abstract class BaseSecurityConfig( .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() - .antMatchers("/api/config/**").permitAll() // Allow access to logo and icon - .antMatchers("/api/login").permitAll() // Allow access to login - .antMatchers("**").fullyAuthenticated() +// .antMatchers("/api/config/**").permitAll() // Allow access to logo and icon +// .antMatchers("/api/account/login/group").permitAll() // Allow access to login +// .antMatchers("**").fullyAuthenticated() + .antMatchers("**").permitAll() - if (debugMode) { + if (Constants.DEBUG_MODE) { http .cors() } @@ -103,14 +120,17 @@ abstract class BaseSecurityConfig( @PostConstruct fun initDebugMode() { - debugMode = "debug" in environment.activeProfiles + val debugMode = "debug" in environment.activeProfiles if (debugMode) logger.warn("Debug mode is enabled, security will be decreased!") + + Constants.DEBUG_MODE = debugMode } protected open fun updateUserLoginTime(userId: Long) { } } +@Order(2) @Configuration @Profile("!emergency") @EnableWebSecurity @@ -120,9 +140,10 @@ class SecurityConfig( @Lazy userDetailsLogic: UserDetailsLogic, @Lazy private val userLogic: UserLogic, jwtLogic: JwtLogic, + groupTokenLogic: GroupTokenLogic, environment: Environment, securityProperties: CreSecurityProperties -) : BaseSecurityConfig(userDetailsLogic, jwtLogic, environment, securityProperties) { +) : BaseSecurityConfig(userDetailsLogic, jwtLogic, groupTokenLogic, environment, securityProperties) { override val logger = KotlinLogging.logger {} @PostConstruct @@ -168,9 +189,10 @@ class SecurityConfig( class EmergencySecurityConfig( userDetailsLogic: UserDetailsLogic, jwtLogic: JwtLogic, + groupTokenLogic: GroupTokenLogic, environment: Environment, securityProperties: CreSecurityProperties -) : BaseSecurityConfig(userDetailsLogic, jwtLogic, environment, securityProperties) { +) : BaseSecurityConfig(userDetailsLogic, jwtLogic, groupTokenLogic, environment, securityProperties) { override val logger = KotlinLogging.logger {} init { @@ -187,5 +209,5 @@ class RestAuthenticationEntryPoint : AuthenticationEntryPoint { ) = response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized") } -private class InvalidSystemUserException(userType: String, message: String) : +class InvalidSystemUserException(userType: String, message: String) : RuntimeException("Invalid $userType user: $message") diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/GroupTokenAuthenticationFilter.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/GroupTokenAuthenticationFilter.kt new file mode 100644 index 0000000..685bf8a --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/GroupTokenAuthenticationFilter.kt @@ -0,0 +1,35 @@ +package dev.fyloz.colorrecipesexplorer.config.security.filters + +import dev.fyloz.colorrecipesexplorer.Constants +import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties +import dev.fyloz.colorrecipesexplorer.config.security.GroupAuthenticationToken +import dev.fyloz.colorrecipesexplorer.dtos.account.UserDetails +import dev.fyloz.colorrecipesexplorer.logic.account.JwtLogic +import org.springframework.security.authentication.AuthenticationManager +import org.springframework.security.authentication.BadCredentialsException +import org.springframework.security.core.Authentication +import org.springframework.web.util.WebUtils +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +class GroupTokenAuthenticationFilter( + jwtLogic: JwtLogic, + securityProperties: CreSecurityProperties, + private val authManager: AuthenticationManager +) : JwtAuthenticationFilter(Constants.ControllerPaths.GROUP_LOGIN, securityProperties, jwtLogic) { + override fun attemptAuthentication(request: HttpServletRequest, response: HttpServletResponse): Authentication { + val groupTokenCookie = getGroupTokenCookie(request) + ?: throw BadCredentialsException("Required group token cookie was not present") + val groupTokenId = groupTokenCookie.value + + logger.debug("Login attempt for group token $groupTokenId") + return authManager.authenticate(GroupAuthenticationToken(groupTokenId)) + } + + override fun afterSuccessfulAuthentication(userDetails: UserDetails) { + logger.info("Successful login for group id '${userDetails.groupId}' using token '${userDetails.id}' (${userDetails.username})") + } + + private fun getGroupTokenCookie(request: HttpServletRequest) = + WebUtils.getCookie(request, Constants.CookieNames.GROUP_TOKEN) +} \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthenticationFilter.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthenticationFilter.kt new file mode 100644 index 0000000..95136eb --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthenticationFilter.kt @@ -0,0 +1,63 @@ +package dev.fyloz.colorrecipesexplorer.config.security.filters + +import dev.fyloz.colorrecipesexplorer.Constants +import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties +import dev.fyloz.colorrecipesexplorer.dtos.account.UserDetails +import dev.fyloz.colorrecipesexplorer.logic.account.JwtLogic +import dev.fyloz.colorrecipesexplorer.utils.addCookie +import org.springframework.http.HttpMethod +import org.springframework.security.core.Authentication +import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter +import org.springframework.security.web.util.matcher.AntPathRequestMatcher +import javax.servlet.FilterChain +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +abstract class JwtAuthenticationFilter( + filterProcessesUrl: String, + private val securityProperties: CreSecurityProperties, + private val jwtLogic: JwtLogic +) : + AbstractAuthenticationProcessingFilter( + AntPathRequestMatcher(filterProcessesUrl, HttpMethod.POST.toString()) + ) { + override fun successfulAuthentication( + request: HttpServletRequest, + response: HttpServletResponse, + chain: FilterChain, + auth: Authentication + ) { + val userDetails = auth.principal as UserDetails + val token = jwtLogic.buildJwt(userDetails) + + addAuthorizationHeaders(response, token) + addAuthorizationCookie(response, token) + + afterSuccessfulAuthentication(userDetails) + } + + protected abstract fun afterSuccessfulAuthentication(userDetails: UserDetails) + + private fun addAuthorizationHeaders(response: HttpServletResponse, token: String) { + response.addHeader(Constants.HeaderNames.ACCESS_CONTROL_EXPOSE_HEADERS, Constants.HeaderNames.AUTHORIZATION) + response.addHeader(Constants.HeaderNames.AUTHORIZATION, "$BEARER_TOKEN_PREFIX $token") + } + + private fun addAuthorizationCookie(response: HttpServletResponse, token: String) { + response.addCookie(Constants.CookieNames.AUTHORIZATION, BEARER_TOKEN_PREFIX + token) { + httpOnly = AUTHORIZATION_COOKIE_HTTP_ONLY + sameSite = AUTHORIZATION_COOKIE_SAME_SITE + secure = !Constants.DEBUG_MODE + maxAge = securityProperties.jwtDuration / 1000 + path = AUTHORIZATION_COOKIE_PATH + } + } + + companion object { + private const val AUTHORIZATION_COOKIE_HTTP_ONLY = true + private const val AUTHORIZATION_COOKIE_SAME_SITE = true + private const val AUTHORIZATION_COOKIE_PATH = Constants.ControllerPaths.BASE_PATH + + private const val BEARER_TOKEN_PREFIX = "Bearer" + } +} \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/JwtFilters.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthorizationFilter.kt similarity index 55% rename from src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/JwtFilters.kt rename to src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthorizationFilter.kt index b7b5f48..8d1a7f5 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/JwtFilters.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthorizationFilter.kt @@ -1,75 +1,20 @@ -package dev.fyloz.colorrecipesexplorer.config.security +package dev.fyloz.colorrecipesexplorer.config.security.filters -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties +import dev.fyloz.colorrecipesexplorer.Constants import dev.fyloz.colorrecipesexplorer.dtos.account.UserDetails -import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto -import dev.fyloz.colorrecipesexplorer.dtos.account.UserLoginRequestDto import dev.fyloz.colorrecipesexplorer.exception.NotFoundException import dev.fyloz.colorrecipesexplorer.logic.account.JwtLogic import dev.fyloz.colorrecipesexplorer.logic.account.UserDetailsLogic -import dev.fyloz.colorrecipesexplorer.utils.addCookie import io.jsonwebtoken.ExpiredJwtException import org.springframework.security.authentication.AuthenticationManager import org.springframework.security.authentication.UsernamePasswordAuthenticationToken -import org.springframework.security.core.Authentication import org.springframework.security.core.context.SecurityContextHolder -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter import org.springframework.security.web.authentication.www.BasicAuthenticationFilter import org.springframework.web.util.WebUtils import javax.servlet.FilterChain import javax.servlet.http.HttpServletRequest import javax.servlet.http.HttpServletResponse -const val authorizationCookieName = "Authorization" -const val defaultGroupCookieName = "Default-Group" -val blacklistedJwtTokens = mutableListOf() // Not working, move to a cache or something - -class JwtAuthenticationFilter( - private val authManager: AuthenticationManager, - private val jwtLogic: JwtLogic, - private val securityProperties: CreSecurityProperties, - private val updateUserLoginTime: (Long) -> Unit -) : UsernamePasswordAuthenticationFilter() { - private var debugMode = false - - init { - setFilterProcessesUrl("/api/login") - debugMode = "debug" in environment.activeProfiles - } - - override fun attemptAuthentication(request: HttpServletRequest, response: HttpServletResponse): Authentication { - val loginRequest = jacksonObjectMapper().readValue(request.inputStream, UserLoginRequestDto::class.java) - logger.debug("Login attempt for user ${loginRequest.id}...") - return authManager.authenticate(UsernamePasswordAuthenticationToken(loginRequest.id, loginRequest.password)) - } - - override fun successfulAuthentication( - request: HttpServletRequest, - response: HttpServletResponse, - chain: FilterChain, - auth: Authentication - ) { - val userDetails = auth.principal as UserDetails - val token = jwtLogic.buildJwt(userDetails) - - with(userDetails.user) { - logger.info("User ${this.id} (${this.firstName} ${this.lastName}) has logged in successfully") - } - - response.addHeader("Access-Control-Expose-Headers", authorizationCookieName) - response.addHeader(authorizationCookieName, "Bearer $token") - response.addCookie(authorizationCookieName, "Bearer$token") { - httpOnly = true - sameSite = true - secure = !debugMode - maxAge = securityProperties.jwtDuration / 1000 - } - - updateUserLoginTime(userDetails.user.id) - } -} - class JwtAuthorizationFilter( private val jwtLogic: JwtLogic, authenticationManager: AuthenticationManager, @@ -77,12 +22,12 @@ class JwtAuthorizationFilter( ) : BasicAuthenticationFilter(authenticationManager) { override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, chain: FilterChain) { fun tryLoginFromBearer(): Boolean { - val authorizationCookie = WebUtils.getCookie(request, authorizationCookieName) + val authorizationCookie = WebUtils.getCookie(request, Constants.HeaderNames.AUTHORIZATION) // Check for an authorization token cookie or header val authorizationToken = if (authorizationCookie != null) authorizationCookie.value else - request.getHeader(authorizationCookieName) + request.getHeader(Constants.HeaderNames.AUTHORIZATION) // An authorization token is valid if it starts with "Bearer", is not expired and is not blacklisted if (authorizationToken != null && authorizationToken.startsWith("Bearer") && authorizationToken !in blacklistedJwtTokens) { @@ -103,6 +48,7 @@ class JwtAuthorizationFilter( if (!tryLoginFromBearer()) tryLoginFromDefaultGroupCookie() + chain.doFilter(request, response) } @@ -115,7 +61,7 @@ class JwtAuthorizationFilter( } } - private fun getAuthenticationToken(user: UserDto) = + private fun getAuthenticationToken(user: UserDetails) = UsernamePasswordAuthenticationToken(user.id, null, user.authorities) private fun getAuthenticationToken(userId: Long): UsernamePasswordAuthenticationToken? = try { diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/UsernamePasswordAuthenticationFilter.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/UsernamePasswordAuthenticationFilter.kt new file mode 100644 index 0000000..d9d91ec --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/UsernamePasswordAuthenticationFilter.kt @@ -0,0 +1,39 @@ +package dev.fyloz.colorrecipesexplorer.config.security.filters + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import dev.fyloz.colorrecipesexplorer.Constants +import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties +import dev.fyloz.colorrecipesexplorer.dtos.account.UserDetails +import dev.fyloz.colorrecipesexplorer.dtos.account.UserLoginRequestDto +import dev.fyloz.colorrecipesexplorer.logic.account.JwtLogic +import org.springframework.security.authentication.AuthenticationManager +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.Authentication +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +const val defaultGroupCookieName = "Default-Group" +val blacklistedJwtTokens = mutableListOf() + +class UsernamePasswordAuthenticationFilter( + jwtLogic: JwtLogic, + securityProperties: CreSecurityProperties, + private val authManager: AuthenticationManager, + private val updateUserLoginTime: (Long) -> Unit +) : JwtAuthenticationFilter(Constants.ControllerPaths.LOGIN, securityProperties, jwtLogic) { + override fun attemptAuthentication(request: HttpServletRequest, response: HttpServletResponse): Authentication { + val loginRequest = getLoginRequest(request) + val authenticationToken = UsernamePasswordAuthenticationToken(loginRequest.id, loginRequest.password) + + logger.debug("Login attempt for user ${loginRequest.id}") + return authManager.authenticate(authenticationToken) + } + + override fun afterSuccessfulAuthentication(userDetails: UserDetails) { + updateUserLoginTime(userDetails.id as Long) + logger.info("User ${userDetails.id} (${userDetails.username}) has logged in successfully") + } + + private fun getLoginRequest(request: HttpServletRequest) = + jacksonObjectMapper().readValue(request.inputStream, UserLoginRequestDto::class.java) +} \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/UserDto.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/UserDto.kt index 32f2a5f..525a333 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/UserDto.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/UserDto.kt @@ -35,10 +35,7 @@ data class UserDto( val isSystemUser: Boolean = false ) : EntityDto { @get:JsonIgnore - val authorities - get() = permissions - .map { it.toAuthority() } - .toMutableSet() + val fullName = "$firstName $lastName" } data class UserSaveDto( @@ -81,10 +78,22 @@ data class UserUpdateDto( data class UserLoginRequestDto(val id: Long, val password: String) -class UserDetails(val user: UserDto) : SpringUserDetails { - override fun getPassword() = user.password - override fun getUsername() = user.id.toString() - override fun getAuthorities() = user.authorities +class UserDetails( + val id: Any, + private val username: String, + private val password: String, + val groupId: Long?, + val permissions: Collection +) : SpringUserDetails { + constructor(user: UserDto) : this(user.id, user.fullName, user.password, user.group?.id, user.permissions) + + override fun getUsername() = username + override fun getPassword() = password + + @JsonIgnore + override fun getAuthorities() = permissions + .map { it.toAuthority() } + .toMutableList() override fun isAccountNonExpired() = true override fun isAccountNonLocked() = true diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupLogic.kt index 1a734df..8b60e77 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupLogic.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupLogic.kt @@ -2,7 +2,6 @@ package dev.fyloz.colorrecipesexplorer.logic.account import dev.fyloz.colorrecipesexplorer.Constants import dev.fyloz.colorrecipesexplorer.config.annotations.LogicComponent -import dev.fyloz.colorrecipesexplorer.config.security.defaultGroupCookieName import dev.fyloz.colorrecipesexplorer.dtos.account.GroupDto import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto import dev.fyloz.colorrecipesexplorer.exception.NoDefaultGroupException @@ -34,7 +33,7 @@ class DefaultGroupLogic(service: GroupService, private val userLogic: UserLogic) override fun getUsersForGroup(id: Long) = userLogic.getAllByGroup(getById(id)) override fun getRequestDefaultGroup(request: HttpServletRequest): GroupDto { - val defaultGroupCookie = WebUtils.getCookie(request, defaultGroupCookieName) + val defaultGroupCookie = WebUtils.getCookie(request, Constants.CookieNames.GROUP_TOKEN) ?: throw NoDefaultGroupException() val defaultGroupUser = userLogic.getById( defaultGroupCookie.value.toLong(), @@ -48,7 +47,7 @@ class DefaultGroupLogic(service: GroupService, private val userLogic: UserLogic) val defaultGroupUser = userLogic.getDefaultGroupUser(getById(id)) response.addHeader( "Set-Cookie", - "$defaultGroupCookieName=${defaultGroupUser.id}; Max-Age=$defaultGroupCookieMaxAge; Path=/api; HttpOnly; Secure; SameSite=strict" + "${Constants.CookieNames.GROUP_TOKEN}=${defaultGroupUser.id}; Max-Age=$defaultGroupCookieMaxAge; Path=/api; HttpOnly; Secure; SameSite=strict" ) } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupTokenLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupTokenLogic.kt index 12211ca..da5d927 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupTokenLogic.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupTokenLogic.kt @@ -13,6 +13,7 @@ import java.util.UUID interface GroupTokenLogic { fun getAll(): Collection fun getById(id: String): GroupTokenDto + fun getById(id: UUID): GroupTokenDto fun save(dto: GroupTokenSaveDto): GroupTokenDto fun deleteById(id: String) } @@ -25,8 +26,10 @@ class DefaultGroupTokenLogic(private val service: GroupTokenService, private val override fun getAll() = service.getAll() - override fun getById(id: String) = - service.getById(UUID.fromString(id)) ?: throw notFoundException(value = id) + override fun getById(id: String) = getById(UUID.fromString(id)) + + override fun getById(id: UUID) = + service.getById(id) ?: throw notFoundException(value = id) override fun save(dto: GroupTokenSaveDto): GroupTokenDto { throwIfNameAlreadyExists(dto.name) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/JwtLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/JwtLogic.kt index 3efbf51..3897561 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/JwtLogic.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/JwtLogic.kt @@ -4,7 +4,6 @@ import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.readValue import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties import dev.fyloz.colorrecipesexplorer.dtos.account.UserDetails -import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto import dev.fyloz.colorrecipesexplorer.utils.base64encode import dev.fyloz.colorrecipesexplorer.utils.toDate import io.jsonwebtoken.Jwts @@ -20,11 +19,8 @@ interface JwtLogic { /** Build a JWT token for the given [userDetails]. */ fun buildJwt(userDetails: UserDetails): String - /** Build a JWT token for the given [user]. */ - fun buildJwt(user: UserDto): String - /** Parses a user from the given [jwt] token. */ - fun parseJwt(jwt: String): UserDto + fun parseJwt(jwt: String): UserDetails } @Service @@ -49,17 +45,14 @@ class DefaultJwtLogic( .build() } - override fun buildJwt(userDetails: UserDetails) = - buildJwt(userDetails.user) - - override fun buildJwt(user: UserDto): String = + override fun buildJwt(userDetails: UserDetails): String = jwtBuilder - .setSubject(user.id.toString()) + .setSubject(userDetails.id.toString()) .setExpiration(getCurrentExpirationDate()) - .claim(jwtClaimUser, user.serialize()) + .claim(jwtClaimUser, userDetails.serialize()) .compact() - override fun parseJwt(jwt: String): UserDto = + override fun parseJwt(jwt: String): UserDetails = with( jwtParser.parseClaimsJws(jwt) .body.get(jwtClaimUser, String::class.java) @@ -72,6 +65,6 @@ class DefaultJwtLogic( .plusSeconds(securityProperties.jwtDuration) .toDate() - private fun UserDto.serialize(): String = + private fun UserDetails.serialize(): String = objectMapper.writeValueAsString(this) } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserLogic.kt index 49d3f5d..07046bc 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserLogic.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserLogic.kt @@ -2,8 +2,7 @@ package dev.fyloz.colorrecipesexplorer.logic.account import dev.fyloz.colorrecipesexplorer.Constants import dev.fyloz.colorrecipesexplorer.config.annotations.LogicComponent -import dev.fyloz.colorrecipesexplorer.config.security.authorizationCookieName -import dev.fyloz.colorrecipesexplorer.config.security.blacklistedJwtTokens +import dev.fyloz.colorrecipesexplorer.config.security.filters.blacklistedJwtTokens import dev.fyloz.colorrecipesexplorer.dtos.account.GroupDto import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto import dev.fyloz.colorrecipesexplorer.dtos.account.UserSaveDto @@ -139,7 +138,7 @@ class DefaultUserLogic( } override fun logout(request: HttpServletRequest) { - val authorizationCookie = WebUtils.getCookie(request, authorizationCookieName) + val authorizationCookie = WebUtils.getCookie(request, Constants.HeaderNames.AUTHORIZATION) if (authorizationCookie != null) { val authorizationToken = authorizationCookie.value if (authorizationToken != null && authorizationToken.startsWith("Bearer")) { diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupTokenController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupTokenController.kt index e2b24aa..79170eb 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupTokenController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupTokenController.kt @@ -2,19 +2,16 @@ package dev.fyloz.colorrecipesexplorer.rest.account import dev.fyloz.colorrecipesexplorer.Constants import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeAdmin +import dev.fyloz.colorrecipesexplorer.dtos.account.GroupTokenDto import dev.fyloz.colorrecipesexplorer.dtos.account.GroupTokenSaveDto import dev.fyloz.colorrecipesexplorer.logic.account.GroupTokenLogic import dev.fyloz.colorrecipesexplorer.rest.created import dev.fyloz.colorrecipesexplorer.rest.noContent import dev.fyloz.colorrecipesexplorer.rest.ok +import dev.fyloz.colorrecipesexplorer.utils.addCookie import org.springframework.context.annotation.Profile -import org.springframework.web.bind.annotation.DeleteMapping -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.* +import javax.servlet.http.HttpServletResponse import javax.validation.Valid @RestController @@ -28,13 +25,38 @@ class GroupTokenController(private val groupTokenLogic: GroupTokenLogic) { @GetMapping("{id}") fun getById(@PathVariable id: String) = ok(groupTokenLogic.getById(id)) - @PostMapping - fun save(@RequestBody @Valid dto: GroupTokenSaveDto) = with(groupTokenLogic.save(dto)) { - created(Constants.ControllerPaths.GROUP_TOKEN, this, this.id) + @GetMapping("{id}/cookie") + fun addCookieForId(@PathVariable id: String, response: HttpServletResponse) { + val groupToken = groupTokenLogic.getById(id) + addGroupTokenCookie(response, groupToken) } + @PostMapping + fun save(@RequestBody @Valid dto: GroupTokenSaveDto, response: HttpServletResponse) = + with(groupTokenLogic.save(dto)) { + addGroupTokenCookie(response, this) + created(Constants.ControllerPaths.GROUP_TOKEN, this, this.id) + } + @DeleteMapping("{id}") fun deleteById(@PathVariable id: String) = noContent { groupTokenLogic.deleteById(id) } + + private fun addGroupTokenCookie(response: HttpServletResponse, groupToken: GroupTokenDto) { + response.addCookie(Constants.CookieNames.GROUP_TOKEN, groupToken.id.toString()) { + httpOnly = GROUP_TOKEN_COOKIE_HTTP_ONLY + sameSite = GROUP_TOKEN_COOKIE_SAME_SITE + secure = !Constants.DEBUG_MODE + maxAge = GROUP_TOKEN_COOKIE_MAX_AGE + path = GROUP_TOKEN_COOKIE_PATH + } + } + + companion object { + private const val GROUP_TOKEN_COOKIE_HTTP_ONLY = true + private const val GROUP_TOKEN_COOKIE_SAME_SITE = true + private const val GROUP_TOKEN_COOKIE_MAX_AGE = Long.MAX_VALUE // This cookie should never expire + private const val GROUP_TOKEN_COOKIE_PATH = Constants.ControllerPaths.GROUP_LOGIN + } } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Http.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Http.kt index b9af339..60db2de 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Http.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Http.kt @@ -1,31 +1,40 @@ package dev.fyloz.colorrecipesexplorer.utils +import dev.fyloz.colorrecipesexplorer.Constants import javax.servlet.http.HttpServletResponse -private const val defaultCookieMaxAge = 3600L -private const val defaultCookieHttpOnly = true -private const val defaultCookieSameSite = true -private const val defaultCookieSecure = true data class CookieBuilderOptions( /** HTTP Only cookies cannot be access by Javascript clients. */ - var httpOnly: Boolean = defaultCookieHttpOnly, + var httpOnly: Boolean = DEFAULT_HTTP_ONLY, /** SameSite cookies are only sent in requests to their origin location. */ - var sameSite: Boolean = defaultCookieSameSite, + var sameSite: Boolean = DEFAULT_SAME_SITE, /** Secure cookies are only sent in HTTPS requests. */ - var secure: Boolean = defaultCookieSecure, + var secure: Boolean = DEFAULT_SECURE, /** Cookie's maximum age in seconds. */ - var maxAge: Long = defaultCookieMaxAge -) + var maxAge: Long = DEFAULT_MAX_AGE, + + /** The path for which the cookie will be sent. */ + var path: String = DEFAULT_PATH +) { + companion object { + private const val DEFAULT_MAX_AGE = 3600L + private const val DEFAULT_HTTP_ONLY = true + private const val DEFAULT_SAME_SITE = true + private const val DEFAULT_SECURE = true + private const val DEFAULT_PATH = Constants.ControllerPaths.BASE_PATH + } +} private enum class CookieBuilderOption(val optionName: String) { HTTP_ONLY("HttpOnly"), SAME_SITE("SameSite"), SECURE("Secure"), - MAX_AGE("Max-Age") + MAX_AGE("Max-Age"), + PATH("Path") } fun HttpServletResponse.addCookie(name: String, value: String, optionsBuilder: CookieBuilderOptions.() -> Unit) { @@ -50,6 +59,7 @@ private fun buildCookie(name: String, value: String, optionsBuilder: CookieBuild addBoolOption(CookieBuilderOption.SAME_SITE, options.sameSite) addBoolOption(CookieBuilderOption.SECURE, options.secure) addOption(CookieBuilderOption.MAX_AGE, options.maxAge) + addOption(CookieBuilderOption.PATH, options.path) return cookie.toString() -} +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 18c7f3f..a899897 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -31,3 +31,5 @@ spring.jackson.default-property-inclusion=non_null spring.profiles.active=@spring.profiles.active@ spring.sql.init.continue-on-error=true + +spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.web.servlet.error.ErrorMvcAutoConfiguration \ No newline at end of file diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultRecipeLogicTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultRecipeLogicTest.kt index 47f5ee1..3fe6d84 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultRecipeLogicTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultRecipeLogicTest.kt @@ -1,6 +1,7 @@ package dev.fyloz.colorrecipesexplorer.logic import dev.fyloz.colorrecipesexplorer.dtos.* +import dev.fyloz.colorrecipesexplorer.dtos.account.GroupDto import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException import dev.fyloz.colorrecipesexplorer.logic.account.GroupLogic import dev.fyloz.colorrecipesexplorer.service.RecipeService From 64349b25e9161d1576fd5104539f4cf760da9ec0 Mon Sep 17 00:00:00 2001 From: FyloZ Date: Thu, 28 Apr 2022 21:29:28 -0400 Subject: [PATCH 03/10] #30 Increase JWT security by removing useless information, return useful information in the login response body instead. Remove default group users from code base. --- .../GroupTokenAuthenticationProvider.kt | 3 +- .../config/security/SecurityConfig.kt | 13 ++-- .../filters/GroupTokenAuthenticationFilter.kt | 2 +- .../filters/JwtAuthenticationFilter.kt | 29 +++++++-- .../filters/JwtAuthorizationFilter.kt | 65 +++++++------------ .../UsernamePasswordAuthenticationFilter.kt | 3 +- .../dtos/account/GroupDto.kt | 9 +-- .../dtos/account/UserDto.kt | 51 +++++++-------- .../logic/account/GroupLogic.kt | 40 +----------- .../logic/account/JwtLogic.kt | 37 +++++++---- .../logic/account/UserDetailsLogic.kt | 6 +- .../logic/account/UserLogic.kt | 38 ++--------- .../repository/AccountRepository.kt | 20 ++++-- .../rest/account/GroupController.kt | 23 +------ .../rest/account/GroupTokenController.kt | 2 + .../service/account/UserService.kt | 35 ++++------ src/main/resources/application.properties | 4 +- .../logic/account/DefaultGroupLogicTest.kt | 1 - 18 files changed, 139 insertions(+), 242 deletions(-) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupTokenAuthenticationProvider.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupTokenAuthenticationProvider.kt index 8150f07..2001f35 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupTokenAuthenticationProvider.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupTokenAuthenticationProvider.kt @@ -16,7 +16,8 @@ class GroupTokenAuthenticationProvider(private val groupTokenLogic: GroupTokenLo val groupTokenId = parseGroupTokenId(groupAuthenticationToken.id) val groupToken = retrieveGroupToken(groupTokenId) - val userDetails = UserDetails(groupToken.id, groupToken.name, "", groupToken.group.id, groupToken.group.permissions) + val userDetails = + UserDetails(groupToken.id.toString(), groupToken.name, "", groupToken.group, groupToken.group.permissions) return UsernamePasswordAuthenticationToken(userDetails, null, userDetails.authorities) } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt index 34485d3..de1d0fe 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt @@ -3,8 +3,8 @@ package dev.fyloz.colorrecipesexplorer.config.security import dev.fyloz.colorrecipesexplorer.Constants import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties import dev.fyloz.colorrecipesexplorer.config.security.filters.GroupTokenAuthenticationFilter -import dev.fyloz.colorrecipesexplorer.config.security.filters.UsernamePasswordAuthenticationFilter import dev.fyloz.colorrecipesexplorer.config.security.filters.JwtAuthorizationFilter +import dev.fyloz.colorrecipesexplorer.config.security.filters.UsernamePasswordAuthenticationFilter import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto import dev.fyloz.colorrecipesexplorer.emergencyMode import dev.fyloz.colorrecipesexplorer.logic.account.GroupTokenLogic @@ -19,7 +19,6 @@ import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Lazy import org.springframework.context.annotation.Profile -import org.springframework.core.annotation.Order import org.springframework.core.env.Environment import org.springframework.http.HttpMethod import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder @@ -102,15 +101,14 @@ abstract class BaseSecurityConfig( BasicAuthenticationFilter::class.java ) .addFilter( - JwtAuthorizationFilter(jwtLogic, authenticationManager(), userDetailsLogic) + JwtAuthorizationFilter(jwtLogic, authenticationManager()) ) .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() -// .antMatchers("/api/config/**").permitAll() // Allow access to logo and icon -// .antMatchers("/api/account/login/group").permitAll() // Allow access to login -// .antMatchers("**").fullyAuthenticated() - .antMatchers("**").permitAll() + .antMatchers("/api/config/**").permitAll() // Allow access to logo and icon + .antMatchers("/api/account/login/**").permitAll() // Allow access to login + .antMatchers("**").fullyAuthenticated() if (Constants.DEBUG_MODE) { http @@ -130,7 +128,6 @@ abstract class BaseSecurityConfig( } } -@Order(2) @Configuration @Profile("!emergency") @EnableWebSecurity diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/GroupTokenAuthenticationFilter.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/GroupTokenAuthenticationFilter.kt index 685bf8a..ede8678 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/GroupTokenAuthenticationFilter.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/GroupTokenAuthenticationFilter.kt @@ -27,7 +27,7 @@ class GroupTokenAuthenticationFilter( } override fun afterSuccessfulAuthentication(userDetails: UserDetails) { - logger.info("Successful login for group id '${userDetails.groupId}' using token '${userDetails.id}' (${userDetails.username})") + logger.info("Successful login for group id '${userDetails.group!!.id}' using token '${userDetails.id}' (${userDetails.username})") } private fun getGroupTokenCookie(request: HttpServletRequest) = diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthenticationFilter.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthenticationFilter.kt index 95136eb..02b9aa9 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthenticationFilter.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthenticationFilter.kt @@ -1,8 +1,10 @@ package dev.fyloz.colorrecipesexplorer.config.security.filters +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import dev.fyloz.colorrecipesexplorer.Constants import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties import dev.fyloz.colorrecipesexplorer.dtos.account.UserDetails +import dev.fyloz.colorrecipesexplorer.dtos.account.UserLoginResponse import dev.fyloz.colorrecipesexplorer.logic.account.JwtLogic import dev.fyloz.colorrecipesexplorer.utils.addCookie import org.springframework.http.HttpMethod @@ -21,6 +23,8 @@ abstract class JwtAuthenticationFilter( AbstractAuthenticationProcessingFilter( AntPathRequestMatcher(filterProcessesUrl, HttpMethod.POST.toString()) ) { + private val jacksonObjectMapper = jacksonObjectMapper() + override fun successfulAuthentication( request: HttpServletRequest, response: HttpServletResponse, @@ -30,19 +34,14 @@ abstract class JwtAuthenticationFilter( val userDetails = auth.principal as UserDetails val token = jwtLogic.buildJwt(userDetails) - addAuthorizationHeaders(response, token) addAuthorizationCookie(response, token) + addResponseBody(userDetails, response) afterSuccessfulAuthentication(userDetails) } protected abstract fun afterSuccessfulAuthentication(userDetails: UserDetails) - private fun addAuthorizationHeaders(response: HttpServletResponse, token: String) { - response.addHeader(Constants.HeaderNames.ACCESS_CONTROL_EXPOSE_HEADERS, Constants.HeaderNames.AUTHORIZATION) - response.addHeader(Constants.HeaderNames.AUTHORIZATION, "$BEARER_TOKEN_PREFIX $token") - } - private fun addAuthorizationCookie(response: HttpServletResponse, token: String) { response.addCookie(Constants.CookieNames.AUTHORIZATION, BEARER_TOKEN_PREFIX + token) { httpOnly = AUTHORIZATION_COOKIE_HTTP_ONLY @@ -53,11 +52,27 @@ abstract class JwtAuthenticationFilter( } } + private fun addResponseBody(userDetails: UserDetails, response: HttpServletResponse) { + val body = getResponseBody(userDetails) + val serializedBody = jacksonObjectMapper.writeValueAsString(body) + + response.writer.println(serializedBody) + } + + private fun getResponseBody(userDetails: UserDetails) = + UserLoginResponse( + userDetails.id, + userDetails.username, + userDetails.group?.id, + userDetails.group?.name, + userDetails.permissions + ) + companion object { private const val AUTHORIZATION_COOKIE_HTTP_ONLY = true private const val AUTHORIZATION_COOKIE_SAME_SITE = true private const val AUTHORIZATION_COOKIE_PATH = Constants.ControllerPaths.BASE_PATH - private const val BEARER_TOKEN_PREFIX = "Bearer" + const val BEARER_TOKEN_PREFIX = "Bearer" } } \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthorizationFilter.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthorizationFilter.kt index 8d1a7f5..d7f8cb5 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthorizationFilter.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthorizationFilter.kt @@ -1,13 +1,12 @@ package dev.fyloz.colorrecipesexplorer.config.security.filters import dev.fyloz.colorrecipesexplorer.Constants -import dev.fyloz.colorrecipesexplorer.dtos.account.UserDetails -import dev.fyloz.colorrecipesexplorer.exception.NotFoundException import dev.fyloz.colorrecipesexplorer.logic.account.JwtLogic -import dev.fyloz.colorrecipesexplorer.logic.account.UserDetailsLogic +import dev.fyloz.colorrecipesexplorer.logic.account.UserJwt import io.jsonwebtoken.ExpiredJwtException import org.springframework.security.authentication.AuthenticationManager import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.Authentication import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.web.authentication.www.BasicAuthenticationFilter import org.springframework.web.util.WebUtils @@ -17,60 +16,42 @@ import javax.servlet.http.HttpServletResponse class JwtAuthorizationFilter( private val jwtLogic: JwtLogic, - authenticationManager: AuthenticationManager, - private val userDetailsLogic: UserDetailsLogic + authenticationManager: AuthenticationManager ) : BasicAuthenticationFilter(authenticationManager) { override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, chain: FilterChain) { - fun tryLoginFromBearer(): Boolean { - val authorizationCookie = WebUtils.getCookie(request, Constants.HeaderNames.AUTHORIZATION) - // Check for an authorization token cookie or header - val authorizationToken = if (authorizationCookie != null) - authorizationCookie.value - else - request.getHeader(Constants.HeaderNames.AUTHORIZATION) + val authorizationCookie = WebUtils.getCookie(request, Constants.HeaderNames.AUTHORIZATION) - // An authorization token is valid if it starts with "Bearer", is not expired and is not blacklisted - if (authorizationToken != null && authorizationToken.startsWith("Bearer") && authorizationToken !in blacklistedJwtTokens) { - val authenticationToken = getAuthentication(authorizationToken) ?: return false - SecurityContextHolder.getContext().authentication = authenticationToken - return true - } - return false + // If there is no authorization cookie, the user is not authenticated + if (authorizationCookie == null) { + chain.doFilter(request, response) + return } - fun tryLoginFromDefaultGroupCookie() { - val defaultGroupCookie = WebUtils.getCookie(request, defaultGroupCookieName) - if (defaultGroupCookie != null) { - val authenticationToken = getAuthenticationToken(defaultGroupCookie.value) - SecurityContextHolder.getContext().authentication = authenticationToken - } + val authorizationToken = authorizationCookie.value + if (!isJwtValid(authorizationToken)) { + chain.doFilter(request, response) + return } - if (!tryLoginFromBearer()) - tryLoginFromDefaultGroupCookie() - + SecurityContextHolder.getContext().authentication = getAuthentication(authorizationToken) chain.doFilter(request, response) } - private fun getAuthentication(token: String): UsernamePasswordAuthenticationToken? { + // The authorization token is valid if it starts with "Bearer" + private fun isJwtValid(authorizationToken: String) = + authorizationToken.startsWith(JwtAuthenticationFilter.BEARER_TOKEN_PREFIX) + + private fun getAuthentication(authorizationToken: String): Authentication? { return try { - val user = jwtLogic.parseJwt(token.replace("Bearer", "")) - getAuthenticationToken(user) + val jwt = authorizationToken.replace(JwtAuthenticationFilter.BEARER_TOKEN_PREFIX, "").trim() + val user = jwtLogic.parseJwt(jwt) + + getAuthentication(user) } catch (_: ExpiredJwtException) { null } } - private fun getAuthenticationToken(user: UserDetails) = + private fun getAuthentication(user: UserJwt) = UsernamePasswordAuthenticationToken(user.id, null, user.authorities) - - private fun getAuthenticationToken(userId: Long): UsernamePasswordAuthenticationToken? = try { - val userDetails = userDetailsLogic.loadUserById(userId) - UsernamePasswordAuthenticationToken(userDetails.username, null, userDetails.authorities) - } catch (_: NotFoundException) { - null - } - - private fun getAuthenticationToken(userId: String) = - getAuthenticationToken(userId.toLong()) } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/UsernamePasswordAuthenticationFilter.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/UsernamePasswordAuthenticationFilter.kt index d9d91ec..cc44d14 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/UsernamePasswordAuthenticationFilter.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/UsernamePasswordAuthenticationFilter.kt @@ -12,7 +12,6 @@ import org.springframework.security.core.Authentication import javax.servlet.http.HttpServletRequest import javax.servlet.http.HttpServletResponse -const val defaultGroupCookieName = "Default-Group" val blacklistedJwtTokens = mutableListOf() class UsernamePasswordAuthenticationFilter( @@ -30,7 +29,7 @@ class UsernamePasswordAuthenticationFilter( } override fun afterSuccessfulAuthentication(userDetails: UserDetails) { - updateUserLoginTime(userDetails.id as Long) + updateUserLoginTime(userDetails.id.toLong()) logger.info("User ${userDetails.id} (${userDetails.username}) has logged in successfully") } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/GroupDto.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/GroupDto.kt index 8e5a61c..c756779 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/GroupDto.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/GroupDto.kt @@ -16,11 +16,4 @@ data class GroupDto( val permissions: List, val explicitPermissions: List = listOf() -) : EntityDto { - @get:JsonIgnore - val defaultGroupUserId = getDefaultGroupUserId(id) - - companion object { - fun getDefaultGroupUserId(id: Long) = 1000000 + id - } -} +) : EntityDto diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/UserDto.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/UserDto.kt index 525a333..1dbb98a 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/UserDto.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/UserDto.kt @@ -17,8 +17,7 @@ data class UserDto( val lastName: String, - @field:JsonIgnore - val password: String = "", + @field:JsonIgnore val password: String = "", val group: GroupDto?, @@ -28,11 +27,7 @@ data class UserDto( val lastLoginTime: LocalDateTime? = null, - @field:JsonIgnore - val isDefaultGroupUser: Boolean = false, - - @field:JsonIgnore - val isSystemUser: Boolean = false + @field:JsonIgnore val isSystemUser: Boolean = false ) : EntityDto { @get:JsonIgnore val fullName = "$firstName $lastName" @@ -41,35 +36,29 @@ data class UserDto( data class UserSaveDto( val id: Long = 0L, - @field:NotBlank - val firstName: String, + @field:NotBlank val firstName: String, - @field:NotBlank - val lastName: String, + @field:NotBlank val lastName: String, - @field:NotBlank - @field:Size(min = 8, message = Constants.ValidationMessages.PASSWORD_TOO_SMALL) - val password: String, + @field:NotBlank @field:Size( + min = 8, message = Constants.ValidationMessages.PASSWORD_TOO_SMALL + ) val password: String, val groupId: Long?, val permissions: List, - @field:JsonIgnore - val isSystemUser: Boolean = false, + @field:JsonIgnore val isSystemUser: Boolean = false, - @field:JsonIgnore - val isDefaultGroupUser: Boolean = false + @field:JsonIgnore val isDefaultGroupUser: Boolean = false ) data class UserUpdateDto( val id: Long = 0L, - @field:NotBlank - val firstName: String, + @field:NotBlank val firstName: String, - @field:NotBlank - val lastName: String, + @field:NotBlank val lastName: String, val groupId: Long?, @@ -79,24 +68,30 @@ data class UserUpdateDto( data class UserLoginRequestDto(val id: Long, val password: String) class UserDetails( - val id: Any, + val id: String, private val username: String, private val password: String, - val groupId: Long?, + val group: GroupDto?, val permissions: Collection ) : SpringUserDetails { - constructor(user: UserDto) : this(user.id, user.fullName, user.password, user.group?.id, user.permissions) + constructor(user: UserDto) : this(user.id.toString(), user.fullName, user.password, user.group, user.permissions) override fun getUsername() = username override fun getPassword() = password @JsonIgnore - override fun getAuthorities() = permissions - .map { it.toAuthority() } - .toMutableList() + override fun getAuthorities() = permissions.map { it.toAuthority() }.toMutableList() override fun isAccountNonExpired() = true override fun isAccountNonLocked() = true override fun isCredentialsNonExpired() = true override fun isEnabled() = true } + +data class UserLoginResponse( + val id: String, + val fullName: String, + val groupId: Long?, + val groupName: String?, + val permissions: Collection +) \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupLogic.kt index 8b60e77..8780ddc 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupLogic.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupLogic.kt @@ -4,26 +4,14 @@ import dev.fyloz.colorrecipesexplorer.Constants import dev.fyloz.colorrecipesexplorer.config.annotations.LogicComponent import dev.fyloz.colorrecipesexplorer.dtos.account.GroupDto import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto -import dev.fyloz.colorrecipesexplorer.exception.NoDefaultGroupException import dev.fyloz.colorrecipesexplorer.logic.BaseLogic import dev.fyloz.colorrecipesexplorer.logic.Logic import dev.fyloz.colorrecipesexplorer.service.account.GroupService import org.springframework.transaction.annotation.Transactional -import org.springframework.web.util.WebUtils -import javax.servlet.http.HttpServletRequest -import javax.servlet.http.HttpServletResponse - -const val defaultGroupCookieMaxAge = 10 * 365 * 24 * 60 * 60 // 10 ans interface GroupLogic : Logic { /** Gets all the users of the group with the given [id]. */ fun getUsersForGroup(id: Long): Collection - - /** Gets the default group from a cookie in the given HTTP [request]. */ - fun getRequestDefaultGroup(request: HttpServletRequest): GroupDto - - /** Sets the default group cookie for the given HTTP [response]. */ - fun setResponseDefaultGroup(id: Long, response: HttpServletResponse) } @LogicComponent @@ -32,32 +20,11 @@ class DefaultGroupLogic(service: GroupService, private val userLogic: UserLogic) GroupLogic { override fun getUsersForGroup(id: Long) = userLogic.getAllByGroup(getById(id)) - override fun getRequestDefaultGroup(request: HttpServletRequest): GroupDto { - val defaultGroupCookie = WebUtils.getCookie(request, Constants.CookieNames.GROUP_TOKEN) - ?: throw NoDefaultGroupException() - val defaultGroupUser = userLogic.getById( - defaultGroupCookie.value.toLong(), - isSystemUser = false, - isDefaultGroupUser = true - ) - return defaultGroupUser.group!! - } - - override fun setResponseDefaultGroup(id: Long, response: HttpServletResponse) { - val defaultGroupUser = userLogic.getDefaultGroupUser(getById(id)) - response.addHeader( - "Set-Cookie", - "${Constants.CookieNames.GROUP_TOKEN}=${defaultGroupUser.id}; Max-Age=$defaultGroupCookieMaxAge; Path=/api; HttpOnly; Secure; SameSite=strict" - ) - } - @Transactional override fun save(dto: GroupDto): GroupDto { throwIfNameAlreadyExists(dto.name) - return super.save(dto).also { - userLogic.saveDefaultGroupUser(it) - } + return super.save(dto) } override fun update(dto: GroupDto): GroupDto { @@ -66,11 +33,6 @@ class DefaultGroupLogic(service: GroupService, private val userLogic: UserLogic) return super.update(dto) } - override fun deleteById(id: Long) { - userLogic.deleteById(GroupDto.getDefaultGroupUserId(id)) - super.deleteById(id) - } - private fun throwIfNameAlreadyExists(name: String, id: Long? = null) { if (service.existsByName(name, id)) { throw alreadyExistsException(value = name) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/JwtLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/JwtLogic.kt index 3897561..5938e98 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/JwtLogic.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/JwtLogic.kt @@ -4,11 +4,14 @@ import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.readValue import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties import dev.fyloz.colorrecipesexplorer.dtos.account.UserDetails +import dev.fyloz.colorrecipesexplorer.model.account.Permission +import dev.fyloz.colorrecipesexplorer.model.account.toAuthority import dev.fyloz.colorrecipesexplorer.utils.base64encode import dev.fyloz.colorrecipesexplorer.utils.toDate import io.jsonwebtoken.Jwts import io.jsonwebtoken.jackson.io.JacksonDeserializer import io.jsonwebtoken.jackson.io.JacksonSerializer +import org.springframework.security.core.GrantedAuthority import org.springframework.stereotype.Service import java.time.Instant import java.util.* @@ -19,8 +22,8 @@ interface JwtLogic { /** Build a JWT token for the given [userDetails]. */ fun buildJwt(userDetails: UserDetails): String - /** Parses a user from the given [jwt] token. */ - fun parseJwt(jwt: String): UserDetails + /** Parses a user information from the given [jwt] token. */ + fun parseJwt(jwt: String): UserJwt } @Service @@ -47,24 +50,32 @@ class DefaultJwtLogic( override fun buildJwt(userDetails: UserDetails): String = jwtBuilder - .setSubject(userDetails.id.toString()) + .setSubject(userDetails.id) .setExpiration(getCurrentExpirationDate()) - .claim(jwtClaimUser, userDetails.serialize()) + .claim(JWT_CLAIM_PERMISSIONS, objectMapper.writeValueAsString(userDetails.permissions)) .compact() - override fun parseJwt(jwt: String): UserDetails = - with( - jwtParser.parseClaimsJws(jwt) - .body.get(jwtClaimUser, String::class.java) - ) { - objectMapper.readValue(this) - } + override fun parseJwt(jwt: String): UserJwt { + val parsedJwt = jwtParser.parseClaimsJws(jwt) + + val serializedPermissions = parsedJwt.body.get(JWT_CLAIM_PERMISSIONS, String::class.java) + val permissions = objectMapper.readValue>(serializedPermissions) + + val authorities = permissions + .map { it.toAuthority() } + .toMutableList() + + return UserJwt(parsedJwt.body.subject, authorities) + } private fun getCurrentExpirationDate(): Date = Instant.now() .plusSeconds(securityProperties.jwtDuration) .toDate() - private fun UserDetails.serialize(): String = - objectMapper.writeValueAsString(this) + companion object { + private const val JWT_CLAIM_PERMISSIONS = "permissions" + } } + +data class UserJwt(val id: String, val authorities: Collection) \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserDetailsLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserDetailsLogic.kt index 8a621ab..972dee1 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserDetailsLogic.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserDetailsLogic.kt @@ -32,11 +32,7 @@ class DefaultUserDetailsLogic( } override fun loadUserById(id: Long, isDefaultGroupUser: Boolean): UserDetails { - val user = userLogic.getById( - id, - isSystemUser = true, - isDefaultGroupUser = isDefaultGroupUser - ) + val user = userLogic.getById(id, isSystemUser = true) return UserDetails(user) } } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserLogic.kt index 07046bc..6ca5ad6 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserLogic.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserLogic.kt @@ -23,13 +23,7 @@ interface UserLogic : Logic { fun getAllByGroup(group: GroupDto): Collection /** Gets the user with the given [id]. */ - fun getById(id: Long, isSystemUser: Boolean, isDefaultGroupUser: Boolean): UserDto - - /** Gets the default user of the given [group]. */ - fun getDefaultGroupUser(group: GroupDto): UserDto - - /** Save a default group user for the given [group]. */ - fun saveDefaultGroupUser(group: GroupDto) + fun getById(id: Long, isSystemUser: Boolean): UserDto /** Saves the given [dto]. */ fun save(dto: UserSaveDto): UserDto @@ -57,30 +51,13 @@ interface UserLogic : Logic { class DefaultUserLogic( service: UserService, @Lazy private val groupLogic: GroupLogic, @Lazy private val passwordEncoder: PasswordEncoder ) : BaseLogic(service, Constants.ModelNames.USER), UserLogic { - override fun getAll() = service.getAll(isSystemUser = false, isDefaultGroupUser = false) + override fun getAll() = service.getAll(false) override fun getAllByGroup(group: GroupDto) = service.getAllByGroup(group) - override fun getById(id: Long) = getById(id, isSystemUser = false, isDefaultGroupUser = false) - override fun getById(id: Long, isSystemUser: Boolean, isDefaultGroupUser: Boolean) = - service.getById(id, !isDefaultGroupUser, !isSystemUser) ?: throw notFoundException(value = id) - - override fun getDefaultGroupUser(group: GroupDto) = - service.getDefaultGroupUser(group) ?: throw notFoundException(identifierName = "groupId", value = group.id) - - override fun saveDefaultGroupUser(group: GroupDto) { - save( - UserSaveDto( - id = group.defaultGroupUserId, - firstName = group.name, - lastName = "User", - password = group.name, - groupId = group.id, - permissions = listOf(), - isDefaultGroupUser = true - ) - ) - } + override fun getById(id: Long) = getById(id, false) + override fun getById(id: Long, isSystemUser: Boolean) = + service.getById(id, !isSystemUser) ?: throw notFoundException(value = id) override fun save(dto: UserSaveDto) = save( UserDto( @@ -90,8 +67,7 @@ class DefaultUserLogic( password = passwordEncoder.encode(dto.password), group = if (dto.groupId != null) groupLogic.getById(dto.groupId) else null, permissions = dto.permissions, - isSystemUser = dto.isSystemUser, - isDefaultGroupUser = dto.isDefaultGroupUser + isSystemUser = dto.isSystemUser ) ) @@ -103,7 +79,7 @@ class DefaultUserLogic( } override fun update(dto: UserUpdateDto): UserDto { - val user = getById(dto.id, isSystemUser = false, isDefaultGroupUser = false) + val user = getById(dto.id, isSystemUser = false) return update( user.copy( diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/AccountRepository.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/AccountRepository.kt index 31e8d9f..e530d45 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/AccountRepository.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/AccountRepository.kt @@ -1,28 +1,34 @@ package dev.fyloz.colorrecipesexplorer.repository -import dev.fyloz.colorrecipesexplorer.model.account.GroupToken import dev.fyloz.colorrecipesexplorer.model.account.Group +import dev.fyloz.colorrecipesexplorer.model.account.GroupToken import dev.fyloz.colorrecipesexplorer.model.account.User import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query import org.springframework.stereotype.Repository -import java.util.UUID +import java.util.* +/** + * Default group users are deprecated and should not be used anymore. + * To prevent data loss, they will not be removed from the database, + * but they are excluded from results from the database. + */ @Repository interface UserRepository : JpaRepository { + fun findAllByIsDefaultGroupUserIsFalse(): MutableList + + fun findByIdAndIsDefaultGroupUserIsFalse(id: Long): User? + /** Checks if a user with the given [firstName], [lastName] and a different [id] exists. */ - fun existsByFirstNameAndLastNameAndIdNot(firstName: String, lastName: String, id: Long): Boolean + fun existsByFirstNameAndLastNameAndIdNotAndIsDefaultGroupUserIsFalse(firstName: String, lastName: String, id: Long): Boolean /** Finds all users for the given [group]. */ @Query("SELECT u FROM User u WHERE u.group = :group AND u.isSystemUser IS FALSE AND u.isDefaultGroupUser IS FALSE") fun findAllByGroup(group: Group): Collection /** Finds the user with the given [firstName] and [lastName]. */ + @Query("SELECT u From User u WHERE u.firstName = :firstName AND u.lastName = :lastName") fun findByFirstNameAndLastName(firstName: String, lastName: String): User? - - /** Finds the default user for the given [group]. */ - @Query("SELECT u FROM User u WHERE u.group = :group AND u.isDefaultGroupUser IS TRUE") - fun findDefaultGroupUser(group: Group): User? } @Repository diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupController.kt index ac3971c..3626696 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupController.kt @@ -21,8 +21,7 @@ import javax.validation.Valid @RequestMapping(Constants.ControllerPaths.GROUP) @Profile("!emergency") class GroupController( - private val groupLogic: GroupLogic, - private val userLogic: UserLogic + private val groupLogic: GroupLogic ) { @GetMapping @PreAuthorize("hasAnyAuthority('VIEW_RECIPES', 'VIEW_USERS')") @@ -39,26 +38,6 @@ class GroupController( fun getUsersForGroup(@PathVariable id: Long) = ok(groupLogic.getUsersForGroup(id)) - @PostMapping("default/{groupId}") - @PreAuthorizeViewUsers - fun setDefaultGroup(@PathVariable groupId: Long, response: HttpServletResponse) = - noContent { - groupLogic.setResponseDefaultGroup(groupId, response) - } - - @GetMapping("default") - @PreAuthorizeViewUsers - fun getRequestDefaultGroup(request: HttpServletRequest) = - ok(with(groupLogic) { - getRequestDefaultGroup(request) - }) - - @GetMapping("currentuser") - fun getCurrentGroupUser(request: HttpServletRequest) = - ok(with(groupLogic.getRequestDefaultGroup(request)) { - userLogic.getDefaultGroupUser(this) - }) - @PostMapping @PreAuthorizeEditUsers fun save(@Valid @RequestBody group: GroupDto) = diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupTokenController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupTokenController.kt index 79170eb..75e69f3 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupTokenController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupTokenController.kt @@ -25,6 +25,8 @@ class GroupTokenController(private val groupTokenLogic: GroupTokenLogic) { @GetMapping("{id}") fun getById(@PathVariable id: String) = ok(groupTokenLogic.getById(id)) + // TODO Remove when group tokens will be fully implemented + @Deprecated("Only use for testing purposes") @GetMapping("{id}/cookie") fun addCookieForId(@PathVariable id: String, response: HttpServletResponse) { val groupToken = groupTokenLogic.getById(id) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/account/UserService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/account/UserService.kt index d942ab7..31c5a23 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/account/UserService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/account/UserService.kt @@ -9,49 +9,42 @@ import dev.fyloz.colorrecipesexplorer.model.account.flat import dev.fyloz.colorrecipesexplorer.repository.UserRepository import dev.fyloz.colorrecipesexplorer.service.BaseService import dev.fyloz.colorrecipesexplorer.service.Service -import org.springframework.data.repository.findByIdOrNull interface UserService : Service { /** Checks if a user with the given [firstName] and [lastName] exists. */ fun existsByFirstNameAndLastName(firstName: String, lastName: String, id: Long? = null): Boolean - /** Gets all users, depending on [isSystemUser] and [isDefaultGroupUser]. */ - fun getAll(isSystemUser: Boolean, isDefaultGroupUser: Boolean): Collection + /** Gets all users, depending on [isSystemUser]. */ + fun getAll(isSystemUser: Boolean): Collection /** Gets all users for the given [group]. */ fun getAllByGroup(group: GroupDto): Collection - /** Finds the user with the given [id], depending on [isSystemUser] and [isDefaultGroupUser]. */ - fun getById(id: Long, isSystemUser: Boolean, isDefaultGroupUser: Boolean): UserDto? + /** Finds the user with the given [id], depending on [isSystemUser]. */ + fun getById(id: Long, isSystemUser: Boolean): UserDto? /** Finds the user with the given [firstName] and [lastName]. */ fun getByFirstNameAndLastName(firstName: String, lastName: String): UserDto? - - /** Find the default user for the given [group]. */ - fun getDefaultGroupUser(group: GroupDto): UserDto? } @ServiceComponent class DefaultUserService(repository: UserRepository, private val groupService: GroupService) : BaseService(repository), UserService { override fun existsByFirstNameAndLastName(firstName: String, lastName: String, id: Long?) = - repository.existsByFirstNameAndLastNameAndIdNot(firstName, lastName, id ?: 0L) + repository.existsByFirstNameAndLastNameAndIdNotAndIsDefaultGroupUserIsFalse(firstName, lastName, id ?: 0L) - override fun getAll(isSystemUser: Boolean, isDefaultGroupUser: Boolean) = - repository.findAll() + override fun getAll(isSystemUser: Boolean) = + repository.findAllByIsDefaultGroupUserIsFalse() .filter { isSystemUser || !it.isSystemUser } - .filter { isDefaultGroupUser || !it.isDefaultGroupUser } .map(::toDto) override fun getAllByGroup(group: GroupDto) = repository.findAllByGroup(groupService.toEntity(group)) .map(::toDto) - override fun getById(id: Long, isSystemUser: Boolean, isDefaultGroupUser: Boolean): UserDto? { - val user = repository.findByIdOrNull(id) ?: return null - if ((!isSystemUser && user.isSystemUser) || - !isDefaultGroupUser && user.isDefaultGroupUser - ) { + override fun getById(id: Long, isSystemUser: Boolean): UserDto? { + val user = repository.findByIdAndIsDefaultGroupUserIsFalse(id) ?: return null + if (!isSystemUser && user.isSystemUser) { return null } @@ -63,11 +56,6 @@ class DefaultUserService(repository: UserRepository, private val groupService: G return if (user != null) toDto(user) else null } - override fun getDefaultGroupUser(group: GroupDto): UserDto? { - val user = repository.findDefaultGroupUser(groupService.toEntity(group)) - return if (user != null) toDto(user) else null - } - override fun toDto(entity: User) = UserDto( entity.id, entity.firstName, @@ -77,7 +65,6 @@ class DefaultUserService(repository: UserRepository, private val groupService: G getFlattenPermissions(entity), entity.permissions, entity.lastLoginTime, - entity.isDefaultGroupUser, entity.isSystemUser ) @@ -86,7 +73,7 @@ class DefaultUserService(repository: UserRepository, private val groupService: G dto.firstName, dto.lastName, dto.password, - dto.isDefaultGroupUser, + false, dto.isSystemUser, if (dto.group != null) groupService.toEntity(dto.group) else null, dto.explicitPermissions, diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index a899897..8a271b9 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -30,6 +30,4 @@ spring.jackson.deserialization.fail-on-null-for-primitives=true spring.jackson.default-property-inclusion=non_null spring.profiles.active=@spring.profiles.active@ -spring.sql.init.continue-on-error=true - -spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.web.servlet.error.ErrorMvcAutoConfiguration \ No newline at end of file +spring.sql.init.continue-on-error=true \ No newline at end of file diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultGroupLogicTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultGroupLogicTest.kt index fa5d8bf..2c9ea17 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultGroupLogicTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultGroupLogicTest.kt @@ -25,7 +25,6 @@ class DefaultGroupLogicTest { every { getAllByGroup(any()) } returns listOf() every { getById(any(), any(), any()) } returns user every { getDefaultGroupUser(any()) } returns user - every { saveDefaultGroupUser(any()) } just runs every { deleteById(any()) } just runs } From 531fa252d2483c33a2eef95d52cf91e8184416c3 Mon Sep 17 00:00:00 2001 From: FyloZ Date: Thu, 28 Apr 2022 23:38:33 -0400 Subject: [PATCH 04/10] #30 Use a JWS to store the group token id cookie --- .../fyloz/colorrecipesexplorer/Constants.kt | 1 + .../security/GroupAuthenticationToken.kt | 2 +- .../GroupTokenAuthenticationProvider.kt | 10 +----- .../config/security/SecurityConfig.kt | 3 +- .../filters/GroupTokenAuthenticationFilter.kt | 5 ++- .../filters/JwtAuthenticationFilter.kt | 8 ++--- .../filters/JwtAuthorizationFilter.kt | 7 ++-- .../logic/account/JwtLogic.kt | 32 ++++++++++++++----- .../logic/account/UserDetailsLogic.kt | 10 +++--- .../logic/account/UserLogic.kt | 6 ++-- .../rest/account/GroupController.kt | 1 + .../rest/account/GroupTokenController.kt | 14 +++++--- .../fyloz/colorrecipesexplorer/utils/Http.kt | 10 ++++-- .../logic/DefaultJwtLogicTest.kt | 12 +++---- 14 files changed, 72 insertions(+), 49 deletions(-) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/Constants.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/Constants.kt index ff032b5..6286baa 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/Constants.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/Constants.kt @@ -1,6 +1,7 @@ package dev.fyloz.colorrecipesexplorer object Constants { + val BEARER_PREFIX = "Bearer" var DEBUG_MODE = false // Not really a constant, but should never change after the app startup object ControllerPaths { diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupAuthenticationToken.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupAuthenticationToken.kt index 9bb42a3..6368356 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupAuthenticationToken.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupAuthenticationToken.kt @@ -4,7 +4,7 @@ import org.springframework.security.authentication.AbstractAuthenticationToken import org.springframework.security.core.GrantedAuthority import java.util.* -class GroupAuthenticationToken(val id: String) : AbstractAuthenticationToken(null) { +class GroupAuthenticationToken(val id: UUID) : AbstractAuthenticationToken(null) { override fun getPrincipal() = id // There is no credential needed to log in with a group token, just use the group token id diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupTokenAuthenticationProvider.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupTokenAuthenticationProvider.kt index 2001f35..d865f45 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupTokenAuthenticationProvider.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupTokenAuthenticationProvider.kt @@ -12,9 +12,7 @@ import java.util.* class GroupTokenAuthenticationProvider(private val groupTokenLogic: GroupTokenLogic) : AuthenticationProvider { override fun authenticate(authentication: Authentication): Authentication { val groupAuthenticationToken = authentication as GroupAuthenticationToken - - val groupTokenId = parseGroupTokenId(groupAuthenticationToken.id) - val groupToken = retrieveGroupToken(groupTokenId) + val groupToken = retrieveGroupToken(groupAuthenticationToken.id) val userDetails = UserDetails(groupToken.id.toString(), groupToken.name, "", groupToken.group, groupToken.group.permissions) @@ -24,12 +22,6 @@ class GroupTokenAuthenticationProvider(private val groupTokenLogic: GroupTokenLo override fun supports(authentication: Class<*>) = authentication.isAssignableFrom(GroupAuthenticationToken::class.java) - private fun parseGroupTokenId(id: String) = try { - UUID.fromString(id) - } catch (_: IllegalArgumentException) { - throw BadCredentialsException("Group token id must be a valid UUID") - } - private fun retrieveGroupToken(id: UUID) = try { groupTokenLogic.getById(id) } catch (_: NotFoundException) { diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt index de1d0fe..a9fe540 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt @@ -107,7 +107,8 @@ abstract class BaseSecurityConfig( .and() .authorizeRequests() .antMatchers("/api/config/**").permitAll() // Allow access to logo and icon - .antMatchers("/api/account/login/**").permitAll() // Allow access to login + .antMatchers("/api/account/login").permitAll() // Allow access to login + .antMatchers("/api/account/login/group").permitAll() // Allow access to group login .antMatchers("**").fullyAuthenticated() if (Constants.DEBUG_MODE) { diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/GroupTokenAuthenticationFilter.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/GroupTokenAuthenticationFilter.kt index ede8678..d2c7601 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/GroupTokenAuthenticationFilter.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/GroupTokenAuthenticationFilter.kt @@ -5,6 +5,7 @@ import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties import dev.fyloz.colorrecipesexplorer.config.security.GroupAuthenticationToken import dev.fyloz.colorrecipesexplorer.dtos.account.UserDetails import dev.fyloz.colorrecipesexplorer.logic.account.JwtLogic +import dev.fyloz.colorrecipesexplorer.utils.parseBearer import org.springframework.security.authentication.AuthenticationManager import org.springframework.security.authentication.BadCredentialsException import org.springframework.security.core.Authentication @@ -20,7 +21,9 @@ class GroupTokenAuthenticationFilter( override fun attemptAuthentication(request: HttpServletRequest, response: HttpServletResponse): Authentication { val groupTokenCookie = getGroupTokenCookie(request) ?: throw BadCredentialsException("Required group token cookie was not present") - val groupTokenId = groupTokenCookie.value + + val jwt = parseBearer(groupTokenCookie.value) + val groupTokenId = jwtLogic.parseGroupTokenIdJwt(jwt) logger.debug("Login attempt for group token $groupTokenId") return authManager.authenticate(GroupAuthenticationToken(groupTokenId)) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthenticationFilter.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthenticationFilter.kt index 02b9aa9..08fe21c 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthenticationFilter.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthenticationFilter.kt @@ -18,7 +18,7 @@ import javax.servlet.http.HttpServletResponse abstract class JwtAuthenticationFilter( filterProcessesUrl: String, private val securityProperties: CreSecurityProperties, - private val jwtLogic: JwtLogic + protected val jwtLogic: JwtLogic ) : AbstractAuthenticationProcessingFilter( AntPathRequestMatcher(filterProcessesUrl, HttpMethod.POST.toString()) @@ -32,7 +32,7 @@ abstract class JwtAuthenticationFilter( auth: Authentication ) { val userDetails = auth.principal as UserDetails - val token = jwtLogic.buildJwt(userDetails) + val token = jwtLogic.buildUserJwt(userDetails) addAuthorizationCookie(response, token) addResponseBody(userDetails, response) @@ -43,7 +43,7 @@ abstract class JwtAuthenticationFilter( protected abstract fun afterSuccessfulAuthentication(userDetails: UserDetails) private fun addAuthorizationCookie(response: HttpServletResponse, token: String) { - response.addCookie(Constants.CookieNames.AUTHORIZATION, BEARER_TOKEN_PREFIX + token) { + response.addCookie(Constants.CookieNames.AUTHORIZATION, Constants.BEARER_PREFIX + token) { httpOnly = AUTHORIZATION_COOKIE_HTTP_ONLY sameSite = AUTHORIZATION_COOKIE_SAME_SITE secure = !Constants.DEBUG_MODE @@ -72,7 +72,5 @@ abstract class JwtAuthenticationFilter( private const val AUTHORIZATION_COOKIE_HTTP_ONLY = true private const val AUTHORIZATION_COOKIE_SAME_SITE = true private const val AUTHORIZATION_COOKIE_PATH = Constants.ControllerPaths.BASE_PATH - - const val BEARER_TOKEN_PREFIX = "Bearer" } } \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthorizationFilter.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthorizationFilter.kt index d7f8cb5..52f5a43 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthorizationFilter.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthorizationFilter.kt @@ -3,6 +3,7 @@ package dev.fyloz.colorrecipesexplorer.config.security.filters import dev.fyloz.colorrecipesexplorer.Constants import dev.fyloz.colorrecipesexplorer.logic.account.JwtLogic import dev.fyloz.colorrecipesexplorer.logic.account.UserJwt +import dev.fyloz.colorrecipesexplorer.utils.parseBearer import io.jsonwebtoken.ExpiredJwtException import org.springframework.security.authentication.AuthenticationManager import org.springframework.security.authentication.UsernamePasswordAuthenticationToken @@ -39,12 +40,12 @@ class JwtAuthorizationFilter( // The authorization token is valid if it starts with "Bearer" private fun isJwtValid(authorizationToken: String) = - authorizationToken.startsWith(JwtAuthenticationFilter.BEARER_TOKEN_PREFIX) + authorizationToken.startsWith(Constants.BEARER_PREFIX) private fun getAuthentication(authorizationToken: String): Authentication? { return try { - val jwt = authorizationToken.replace(JwtAuthenticationFilter.BEARER_TOKEN_PREFIX, "").trim() - val user = jwtLogic.parseJwt(jwt) + val jwt = parseBearer(authorizationToken) + val user = jwtLogic.parseUserJwt(jwt) getAuthentication(user) } catch (_: ExpiredJwtException) { diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/JwtLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/JwtLogic.kt index 5938e98..b2b6c38 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/JwtLogic.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/JwtLogic.kt @@ -19,11 +19,17 @@ import java.util.* const val jwtClaimUser = "user" interface JwtLogic { - /** Build a JWT token for the given [userDetails]. */ - fun buildJwt(userDetails: UserDetails): String + /** Build a JWT for the given [userDetails]. */ + fun buildUserJwt(userDetails: UserDetails): String - /** Parses a user information from the given [jwt] token. */ - fun parseJwt(jwt: String): UserJwt + /** Build a JWT for the given [groupTokenId]. */ + fun buildGroupTokenIdJwt(groupTokenId: UUID): String + + /** Parses a user information from the given [jwt]. */ + fun parseUserJwt(jwt: String): UserJwt + + /** Parses a group token id from the given [jwt]. */ + fun parseGroupTokenIdJwt(jwt: String): UUID } @Service @@ -35,11 +41,11 @@ class DefaultJwtLogic( securityProperties.jwtSecret.base64encode() } - private val jwtBuilder by lazy { + // Must be a new instance every time, or data from the last token will still be there + private val jwtBuilder get() = Jwts.builder() .serializeToJsonWith(JacksonSerializer>(objectMapper)) .signWith(secretKey) - } private val jwtParser by lazy { Jwts.parserBuilder() @@ -48,14 +54,19 @@ class DefaultJwtLogic( .build() } - override fun buildJwt(userDetails: UserDetails): String = + override fun buildUserJwt(userDetails: UserDetails): String = jwtBuilder .setSubject(userDetails.id) .setExpiration(getCurrentExpirationDate()) .claim(JWT_CLAIM_PERMISSIONS, objectMapper.writeValueAsString(userDetails.permissions)) .compact() - override fun parseJwt(jwt: String): UserJwt { + override fun buildGroupTokenIdJwt(groupTokenId: UUID): String = + jwtBuilder + .setSubject(groupTokenId.toString()) + .compact() + + override fun parseUserJwt(jwt: String): UserJwt { val parsedJwt = jwtParser.parseClaimsJws(jwt) val serializedPermissions = parsedJwt.body.get(JWT_CLAIM_PERMISSIONS, String::class.java) @@ -68,6 +79,11 @@ class DefaultJwtLogic( return UserJwt(parsedJwt.body.subject, authorities) } + override fun parseGroupTokenIdJwt(jwt: String): UUID { + val uuid = jwtParser.parseClaimsJws(jwt).body.subject + return UUID.fromString(uuid) + } + private fun getCurrentExpirationDate(): Date = Instant.now() .plusSeconds(securityProperties.jwtDuration) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserDetailsLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserDetailsLogic.kt index 972dee1..b62a52c 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserDetailsLogic.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserDetailsLogic.kt @@ -15,7 +15,7 @@ import org.springframework.stereotype.Service interface UserDetailsLogic : SpringUserDetailsService { /** Loads an [User] for the given [id]. */ - fun loadUserById(id: Long, isDefaultGroupUser: Boolean = true): UserDetails + fun loadUserById(id: Long): UserDetails } @Service @@ -25,13 +25,13 @@ class DefaultUserDetailsLogic( ) : UserDetailsLogic { override fun loadUserByUsername(username: String): UserDetails { try { - return loadUserById(username.toLong(), false) + return loadUserById(username.toLong()) } catch (ex: NotFoundException) { throw UsernameNotFoundException(username) } } - override fun loadUserById(id: Long, isDefaultGroupUser: Boolean): UserDetails { + override fun loadUserById(id: Long): UserDetails { val user = userLogic.getById(id, isSystemUser = true) return UserDetails(user) } @@ -65,10 +65,10 @@ class EmergencyUserDetailsLogic( } override fun loadUserByUsername(username: String): SpringUserDetails { - return loadUserById(username.toLong(), false) + return loadUserById(username.toLong()) } - override fun loadUserById(id: Long, isDefaultGroupUser: Boolean): UserDetails { + override fun loadUserById(id: Long): UserDetails { val user = users.firstOrNull { it.id == id } ?: throw UsernameNotFoundException(id.toString()) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserLogic.kt index 6ca5ad6..6725b2c 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserLogic.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserLogic.kt @@ -57,7 +57,7 @@ class DefaultUserLogic( override fun getById(id: Long) = getById(id, false) override fun getById(id: Long, isSystemUser: Boolean) = - service.getById(id, !isSystemUser) ?: throw notFoundException(value = id) + service.getById(id, isSystemUser) ?: throw notFoundException(value = id) override fun save(dto: UserSaveDto) = save( UserDto( @@ -79,7 +79,7 @@ class DefaultUserLogic( } override fun update(dto: UserUpdateDto): UserDto { - val user = getById(dto.id, isSystemUser = false) + val user = getById(dto.id) return update( user.copy( @@ -97,7 +97,7 @@ class DefaultUserLogic( return super.update(dto) } - override fun updateLastLoginTime(id: Long, time: LocalDateTime) = with(getById(id)) { + override fun updateLastLoginTime(id: Long, time: LocalDateTime) = with(getById(id, true)) { update(this.copy(lastLoginTime = time)) } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupController.kt index 3626696..ce394b2 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupController.kt @@ -5,6 +5,7 @@ import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeEditUsers import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeViewUsers import dev.fyloz.colorrecipesexplorer.dtos.account.GroupDto import dev.fyloz.colorrecipesexplorer.logic.account.GroupLogic +import dev.fyloz.colorrecipesexplorer.logic.account.JwtLogic import dev.fyloz.colorrecipesexplorer.logic.account.UserLogic import dev.fyloz.colorrecipesexplorer.rest.created import dev.fyloz.colorrecipesexplorer.rest.noContent diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupTokenController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupTokenController.kt index 75e69f3..b2adbd1 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupTokenController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupTokenController.kt @@ -5,6 +5,7 @@ import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeAdmin import dev.fyloz.colorrecipesexplorer.dtos.account.GroupTokenDto import dev.fyloz.colorrecipesexplorer.dtos.account.GroupTokenSaveDto import dev.fyloz.colorrecipesexplorer.logic.account.GroupTokenLogic +import dev.fyloz.colorrecipesexplorer.logic.account.JwtLogic import dev.fyloz.colorrecipesexplorer.rest.created import dev.fyloz.colorrecipesexplorer.rest.noContent import dev.fyloz.colorrecipesexplorer.rest.ok @@ -18,7 +19,10 @@ import javax.validation.Valid @RequestMapping(Constants.ControllerPaths.GROUP_TOKEN) @PreAuthorizeAdmin @Profile("!emergency") -class GroupTokenController(private val groupTokenLogic: GroupTokenLogic) { +class GroupTokenController( + private val groupTokenLogic: GroupTokenLogic, + private val jwtLogic: JwtLogic +) { @GetMapping fun getAll() = ok(groupTokenLogic.getAll()) @@ -46,11 +50,14 @@ class GroupTokenController(private val groupTokenLogic: GroupTokenLogic) { } private fun addGroupTokenCookie(response: HttpServletResponse, groupToken: GroupTokenDto) { - response.addCookie(Constants.CookieNames.GROUP_TOKEN, groupToken.id.toString()) { + val jwt = jwtLogic.buildGroupTokenIdJwt(groupToken.id) + val bearer = Constants.BEARER_PREFIX + jwt + + response.addCookie(Constants.CookieNames.GROUP_TOKEN, bearer) { httpOnly = GROUP_TOKEN_COOKIE_HTTP_ONLY sameSite = GROUP_TOKEN_COOKIE_SAME_SITE secure = !Constants.DEBUG_MODE - maxAge = GROUP_TOKEN_COOKIE_MAX_AGE + maxAge = null // This cookie should never expire path = GROUP_TOKEN_COOKIE_PATH } } @@ -58,7 +65,6 @@ class GroupTokenController(private val groupTokenLogic: GroupTokenLogic) { companion object { private const val GROUP_TOKEN_COOKIE_HTTP_ONLY = true private const val GROUP_TOKEN_COOKIE_SAME_SITE = true - private const val GROUP_TOKEN_COOKIE_MAX_AGE = Long.MAX_VALUE // This cookie should never expire private const val GROUP_TOKEN_COOKIE_PATH = Constants.ControllerPaths.GROUP_LOGIN } } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Http.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Http.kt index 60db2de..c83f0cb 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Http.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Http.kt @@ -15,7 +15,7 @@ data class CookieBuilderOptions( var secure: Boolean = DEFAULT_SECURE, /** Cookie's maximum age in seconds. */ - var maxAge: Long = DEFAULT_MAX_AGE, + var maxAge: Long? = DEFAULT_MAX_AGE, /** The path for which the cookie will be sent. */ var path: String = DEFAULT_PATH @@ -51,7 +51,8 @@ private fun buildCookie(name: String, value: String, optionsBuilder: CookieBuild } } - fun addOption(option: CookieBuilderOption, value: Any) { + fun addOption(option: CookieBuilderOption, value: Any?) { + if (value == null) return cookie.append("${option.optionName}=$value;") } @@ -62,4 +63,7 @@ private fun buildCookie(name: String, value: String, optionsBuilder: CookieBuild addOption(CookieBuilderOption.PATH, options.path) return cookie.toString() -} \ No newline at end of file +} + +fun parseBearer(source: String) = + source.replace(Constants.BEARER_PREFIX, "").trim() \ No newline at end of file diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultJwtLogicTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultJwtLogicTest.kt index 1e91082..08d784c 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultJwtLogicTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultJwtLogicTest.kt @@ -52,7 +52,7 @@ class DefaultJwtLogicTest { fun buildJwt_userDetails_normalBehavior_returnsJwtStringWithValidUser() { val userDetails = UserDetails(user) - val builtJwt = jwtService.buildJwt(userDetails) + val builtJwt = jwtService.buildUserJwt(userDetails) withParsedUserOutputDto(builtJwt) { parsedUser -> assertEquals(user, parsedUser) @@ -61,7 +61,7 @@ class DefaultJwtLogicTest { @Test fun buildJwt_user_normalBehavior_returnsJwtStringWithValidUser() { - val builtJwt = jwtService.buildJwt(user) + val builtJwt = jwtService.buildUserJwt(user) withParsedUserOutputDto(builtJwt) { parsedUser -> assertEquals(user, parsedUser) @@ -70,7 +70,7 @@ class DefaultJwtLogicTest { @Test fun buildJwt_user_normalBehavior_returnsJwtStringWithValidSubject() { - val builtJwt = jwtService.buildJwt(user) + val builtJwt = jwtService.buildUserJwt(user) val jwtSubject = jwtParser.parseClaimsJws(builtJwt).body.subject assertEquals(user.id.toString(), jwtSubject) @@ -80,7 +80,7 @@ class DefaultJwtLogicTest { fun buildJwt_user_returnsJwtWithValidExpirationDate() { val jwtExpectedExpirationDate = Instant.now().plusSeconds(securityProperties.jwtDuration) - val builtJwt = jwtService.buildJwt(user) + val builtJwt = jwtService.buildUserJwt(user) val jwtExpiration = jwtParser.parseClaimsJws(builtJwt) .body.expiration.toInstant() @@ -92,8 +92,8 @@ class DefaultJwtLogicTest { @Test fun parseJwt_normalBehavior_returnsExpectedUser() { - val jwt = jwtService.buildJwt(user) - val parsedUser = jwtService.parseJwt(jwt) + val jwt = jwtService.buildUserJwt(user) + val parsedUser = jwtService.parseUserJwt(jwt) assertEquals(user, parsedUser) } From ff8ba46ce2f9750b0cc99d804ff83ef495761154 Mon Sep 17 00:00:00 2001 From: william Date: Fri, 29 Apr 2022 17:20:17 -0400 Subject: [PATCH 05/10] #30 Disallow log in and authorization from disabled or removed group tokens --- .../fyloz/colorrecipesexplorer/Constants.kt | 6 +- .../GroupTokenAuthenticationProvider.kt | 40 ++++-- .../config/security/SecurityConfig.kt | 2 +- .../filters/JwtAuthorizationFilter.kt | 16 ++- .../dtos/account/GroupTokenDto.kt | 2 +- .../dtos/account/UserDto.kt | 9 +- .../logic/account/GroupTokenLogic.kt | 45 +++++-- .../logic/account/JwtLogic.kt | 42 ++++--- .../logic/account/UserLogic.kt | 2 +- .../model/account/Permission.kt | 118 +++++++++--------- .../rest/ConfigurationController.kt | 1 - .../rest/account/GroupTokenController.kt | 21 ++-- .../service/account/GroupService.kt | 1 - .../service/account/GroupTokenService.kt | 2 +- .../service/account/UserService.kt | 1 - .../resources/application-debug.properties | 2 +- 16 files changed, 194 insertions(+), 116 deletions(-) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/Constants.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/Constants.kt index 6286baa..6478c24 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/Constants.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/Constants.kt @@ -37,9 +37,9 @@ object Constants { const val RECIPE_IMAGES = "$IMAGES/recipes" } - object HeaderNames { - const val ACCESS_CONTROL_EXPOSE_HEADERS = "Access-Control-Expose-Headers" - const val AUTHORIZATION = "Authorization" + object JwtType { + const val USER = 0 + const val GROUP = 1 } object ModelNames { diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupTokenAuthenticationProvider.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupTokenAuthenticationProvider.kt index d865f45..f0bf5fc 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupTokenAuthenticationProvider.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupTokenAuthenticationProvider.kt @@ -1,30 +1,56 @@ package dev.fyloz.colorrecipesexplorer.config.security +import dev.fyloz.colorrecipesexplorer.dtos.account.GroupTokenDto import dev.fyloz.colorrecipesexplorer.dtos.account.UserDetails import dev.fyloz.colorrecipesexplorer.exception.NotFoundException import dev.fyloz.colorrecipesexplorer.logic.account.GroupTokenLogic +import mu.KotlinLogging import org.springframework.security.authentication.AuthenticationProvider import org.springframework.security.authentication.BadCredentialsException import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.core.Authentication +import org.springframework.security.core.AuthenticationException import java.util.* class GroupTokenAuthenticationProvider(private val groupTokenLogic: GroupTokenLogic) : AuthenticationProvider { + private val logger = KotlinLogging.logger {} + override fun authenticate(authentication: Authentication): Authentication { val groupAuthenticationToken = authentication as GroupAuthenticationToken - val groupToken = retrieveGroupToken(groupAuthenticationToken.id) + + val groupToken = try { + retrieveGroupToken(groupAuthenticationToken.id) + } catch (e: AuthenticationException) { + logger.debug(e.message) + throw e + } val userDetails = - UserDetails(groupToken.id.toString(), groupToken.name, "", groupToken.group, groupToken.group.permissions) + UserDetails( + groupToken.id.toString(), + groupToken.name, + "", + groupToken.group, + groupToken.group.permissions, + true + ) return UsernamePasswordAuthenticationToken(userDetails, null, userDetails.authorities) } override fun supports(authentication: Class<*>) = authentication.isAssignableFrom(GroupAuthenticationToken::class.java) - private fun retrieveGroupToken(id: UUID) = try { - groupTokenLogic.getById(id) - } catch (_: NotFoundException) { - throw BadCredentialsException("Failed to find group token with id '$id'") + private fun retrieveGroupToken(id: UUID): GroupTokenDto { + val groupToken = try { + groupTokenLogic.getById(id) + } catch (_: NotFoundException) { + throw BadCredentialsException("Failed to find group token with id '$id'") + } + + if (groupTokenLogic.isDisabled(groupToken.id.toString())) { + throw BadCredentialsException("Group token '${groupToken.id}' is disabled") + } + + return groupToken } -} \ No newline at end of file +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt index a9fe540..fdb2984 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt @@ -101,7 +101,7 @@ abstract class BaseSecurityConfig( BasicAuthenticationFilter::class.java ) .addFilter( - JwtAuthorizationFilter(jwtLogic, authenticationManager()) + JwtAuthorizationFilter(jwtLogic, groupTokenLogic, authManager) ) .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthorizationFilter.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthorizationFilter.kt index 52f5a43..cb7e30c 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthorizationFilter.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthorizationFilter.kt @@ -1,13 +1,16 @@ package dev.fyloz.colorrecipesexplorer.config.security.filters import dev.fyloz.colorrecipesexplorer.Constants +import dev.fyloz.colorrecipesexplorer.dtos.account.UserJwt +import dev.fyloz.colorrecipesexplorer.logic.account.GroupTokenLogic import dev.fyloz.colorrecipesexplorer.logic.account.JwtLogic -import dev.fyloz.colorrecipesexplorer.logic.account.UserJwt import dev.fyloz.colorrecipesexplorer.utils.parseBearer import io.jsonwebtoken.ExpiredJwtException +import mu.KotlinLogging import org.springframework.security.authentication.AuthenticationManager import org.springframework.security.authentication.UsernamePasswordAuthenticationToken import org.springframework.security.core.Authentication +import org.springframework.security.core.AuthenticationException import org.springframework.security.core.context.SecurityContextHolder import org.springframework.security.web.authentication.www.BasicAuthenticationFilter import org.springframework.web.util.WebUtils @@ -17,10 +20,11 @@ import javax.servlet.http.HttpServletResponse class JwtAuthorizationFilter( private val jwtLogic: JwtLogic, + private val groupTokenLogic: GroupTokenLogic, authenticationManager: AuthenticationManager ) : BasicAuthenticationFilter(authenticationManager) { override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, chain: FilterChain) { - val authorizationCookie = WebUtils.getCookie(request, Constants.HeaderNames.AUTHORIZATION) + val authorizationCookie = WebUtils.getCookie(request, Constants.CookieNames.AUTHORIZATION) // If there is no authorization cookie, the user is not authenticated if (authorizationCookie == null) { @@ -30,6 +34,8 @@ class JwtAuthorizationFilter( val authorizationToken = authorizationCookie.value if (!isJwtValid(authorizationToken)) { + logger.debug("Received request with invalid ${Constants.CookieNames.AUTHORIZATION} cookie") + chain.doFilter(request, response) return } @@ -47,8 +53,14 @@ class JwtAuthorizationFilter( val jwt = parseBearer(authorizationToken) val user = jwtLogic.parseUserJwt(jwt) + if (user.isGroup && groupTokenLogic.isDisabled(user.id)) { + logger.debug("Rejected authorization for disabled group token '${user.id}'") + return null + } + getAuthentication(user) } catch (_: ExpiredJwtException) { + logger.debug("Rejected authorization for expired JWT") null } } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/GroupTokenDto.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/GroupTokenDto.kt index 8708ed2..6277fdc 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/GroupTokenDto.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/GroupTokenDto.kt @@ -8,7 +8,7 @@ data class GroupTokenDto( val name: String, - val isValid: Boolean, + val enabled: Boolean, val group: GroupDto ) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/UserDto.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/UserDto.kt index 1dbb98a..6216a76 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/UserDto.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/UserDto.kt @@ -5,7 +5,7 @@ import dev.fyloz.colorrecipesexplorer.Constants import dev.fyloz.colorrecipesexplorer.SpringUserDetails import dev.fyloz.colorrecipesexplorer.dtos.EntityDto import dev.fyloz.colorrecipesexplorer.model.account.Permission -import dev.fyloz.colorrecipesexplorer.model.account.toAuthority +import org.springframework.security.core.GrantedAuthority import java.time.LocalDateTime import javax.validation.constraints.NotBlank import javax.validation.constraints.Size @@ -67,12 +67,15 @@ data class UserUpdateDto( data class UserLoginRequestDto(val id: Long, val password: String) +data class UserJwt(val id: String, val authorities: Collection, val isGroup: Boolean) + class UserDetails( val id: String, private val username: String, private val password: String, val group: GroupDto?, - val permissions: Collection + val permissions: Collection, + val isGroup: Boolean = false ) : SpringUserDetails { constructor(user: UserDto) : this(user.id.toString(), user.fullName, user.password, user.group, user.permissions) @@ -94,4 +97,4 @@ data class UserLoginResponse( val groupId: Long?, val groupName: String?, val permissions: Collection -) \ No newline at end of file +) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupTokenLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupTokenLogic.kt index da5d927..f689530 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupTokenLogic.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupTokenLogic.kt @@ -8,13 +8,17 @@ import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException import dev.fyloz.colorrecipesexplorer.exception.NotFoundException import dev.fyloz.colorrecipesexplorer.logic.BaseLogic import dev.fyloz.colorrecipesexplorer.service.account.GroupTokenService -import java.util.UUID +import java.util.* +import javax.annotation.PostConstruct interface GroupTokenLogic { + fun isDisabled(id: String): Boolean fun getAll(): Collection fun getById(id: String): GroupTokenDto fun getById(id: UUID): GroupTokenDto fun save(dto: GroupTokenSaveDto): GroupTokenDto + fun enable(id: String): GroupTokenDto + fun disable(id: String): GroupTokenDto fun deleteById(id: String) } @@ -24,30 +28,50 @@ class DefaultGroupTokenLogic(private val service: GroupTokenService, private val private val typeName = Constants.ModelNames.GROUP_TOKEN private val typeNameLowerCase = typeName.lowercase() - override fun getAll() = service.getAll() + private val enabledTokensCache = hashSetOf() + @PostConstruct + fun initEnabledTokensCache() { + val tokensIds = getAll().filter { it.enabled }.map { it.id.toString() } + enabledTokensCache.addAll(tokensIds) + } + + override fun isDisabled(id: String) = !enabledTokensCache.contains(id) + override fun getAll() = service.getAll() override fun getById(id: String) = getById(UUID.fromString(id)) - override fun getById(id: UUID) = - service.getById(id) ?: throw notFoundException(value = id) + override fun getById(id: UUID) = service.getById(id) ?: throw notFoundException(value = id) override fun save(dto: GroupTokenSaveDto): GroupTokenDto { throwIfNameAlreadyExists(dto.name) val token = GroupTokenDto( - generateUniqueUUIDForName(dto.name), - dto.name, - true, - groupLogic.getById(dto.groupId) + generateUniqueUUIDForName(dto.name), dto.name, true, groupLogic.getById(dto.groupId) ) - return service.save(token) + val savedToken = service.save(token) + enabledTokensCache.add(savedToken.id.toString()) + + return savedToken + } + + override fun enable(id: String) = setEnabled(id, true).also { + enabledTokensCache.add(id) + } + + override fun disable(id: String) = setEnabled(id, false).also { + enabledTokensCache.remove(id) } override fun deleteById(id: String) { + 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 generateUniqueUUIDForName(name: String): UUID { var id = generateUUIDForName(name) @@ -59,8 +83,7 @@ class DefaultGroupTokenLogic(private val service: GroupTokenService, private val return id } - private fun generateUUIDForName(name: String) = - UUID.nameUUIDFromBytes(name.toByteArray()) + private fun generateUUIDForName(name: String) = UUID.nameUUIDFromBytes(name.toByteArray()) private fun throwIfNameAlreadyExists(name: String) { if (service.existsByName(name)) { diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/JwtLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/JwtLogic.kt index b2b6c38..4b0a971 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/JwtLogic.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/JwtLogic.kt @@ -4,14 +4,13 @@ import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.readValue import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties import dev.fyloz.colorrecipesexplorer.dtos.account.UserDetails +import dev.fyloz.colorrecipesexplorer.dtos.account.UserJwt import dev.fyloz.colorrecipesexplorer.model.account.Permission -import dev.fyloz.colorrecipesexplorer.model.account.toAuthority import dev.fyloz.colorrecipesexplorer.utils.base64encode import dev.fyloz.colorrecipesexplorer.utils.toDate import io.jsonwebtoken.Jwts import io.jsonwebtoken.jackson.io.JacksonDeserializer import io.jsonwebtoken.jackson.io.JacksonSerializer -import org.springframework.security.core.GrantedAuthority import org.springframework.stereotype.Service import java.time.Instant import java.util.* @@ -41,11 +40,15 @@ class DefaultJwtLogic( securityProperties.jwtSecret.base64encode() } + private val permissionsById = Permission.values() + .associateBy { it.id } + // Must be a new instance every time, or data from the last token will still be there - private val jwtBuilder get() = - Jwts.builder() - .serializeToJsonWith(JacksonSerializer>(objectMapper)) - .signWith(secretKey) + private val jwtBuilder + get() = + Jwts.builder() + .serializeToJsonWith(JacksonSerializer>(objectMapper)) + .signWith(secretKey) private val jwtParser by lazy { Jwts.parserBuilder() @@ -54,12 +57,17 @@ class DefaultJwtLogic( .build() } - override fun buildUserJwt(userDetails: UserDetails): String = - jwtBuilder + override fun buildUserJwt(userDetails: UserDetails): String { + val permissionsIds = userDetails.permissions.map { it.id } + val type = if (userDetails.isGroup) JWT_TYPE_GROUP else JWT_TYPE_USER + + return jwtBuilder .setSubject(userDetails.id) .setExpiration(getCurrentExpirationDate()) - .claim(JWT_CLAIM_PERMISSIONS, objectMapper.writeValueAsString(userDetails.permissions)) + .claim(JWT_CLAIM_PERMISSIONS, objectMapper.writeValueAsString(permissionsIds)) + .claim(JWT_CLAIM_TYPE, type) .compact() + } override fun buildGroupTokenIdJwt(groupTokenId: UUID): String = jwtBuilder @@ -70,13 +78,17 @@ class DefaultJwtLogic( val parsedJwt = jwtParser.parseClaimsJws(jwt) val serializedPermissions = parsedJwt.body.get(JWT_CLAIM_PERMISSIONS, String::class.java) - val permissions = objectMapper.readValue>(serializedPermissions) + val permissionsIds = objectMapper.readValue>(serializedPermissions) + val permissions = permissionsIds.map { permissionsById[it]!! } + + val type = parsedJwt.body[JWT_CLAIM_TYPE] as Int + val isGroup = type == JWT_TYPE_GROUP val authorities = permissions .map { it.toAuthority() } .toMutableList() - return UserJwt(parsedJwt.body.subject, authorities) + return UserJwt(parsedJwt.body.subject, authorities, isGroup) } override fun parseGroupTokenIdJwt(jwt: String): UUID { @@ -90,8 +102,10 @@ class DefaultJwtLogic( .toDate() companion object { - private const val JWT_CLAIM_PERMISSIONS = "permissions" + private const val JWT_CLAIM_PERMISSIONS = "perms" + private const val JWT_CLAIM_TYPE = "type" + + private const val JWT_TYPE_USER = 0 + private const val JWT_TYPE_GROUP = 1 } } - -data class UserJwt(val id: String, val authorities: Collection) \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserLogic.kt index 6725b2c..3916d89 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserLogic.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserLogic.kt @@ -114,7 +114,7 @@ class DefaultUserLogic( } override fun logout(request: HttpServletRequest) { - val authorizationCookie = WebUtils.getCookie(request, Constants.HeaderNames.AUTHORIZATION) + val authorizationCookie = WebUtils.getCookie(request, Constants.CookieNames.AUTHORIZATION) if (authorizationCookie != null) { val authorizationToken = authorizationCookie.value if (authorizationToken != null && authorizationToken.startsWith("Bearer")) { diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/account/Permission.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/account/Permission.kt index abd2fb0..ecc0928 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/account/Permission.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/account/Permission.kt @@ -4,32 +4,34 @@ import org.springframework.security.core.GrantedAuthority import org.springframework.security.core.authority.SimpleGrantedAuthority enum class Permission( - val impliedPermissions: List = listOf(), + val id: Int, + private val impliedPermissions: List = listOf(), val deprecated: Boolean = false ) { - READ_FILE, - WRITE_FILE(listOf(READ_FILE)), + READ_FILE(0), + WRITE_FILE(1, listOf(READ_FILE)), - VIEW_RECIPES(listOf(READ_FILE)), - VIEW_CATALOG(listOf(READ_FILE)), - VIEW_USERS, + VIEW_RECIPES(2, listOf(READ_FILE)), + VIEW_CATALOG(3, listOf(READ_FILE)), + VIEW_USERS(4), - EDIT_RECIPES_PUBLIC_DATA(listOf(VIEW_RECIPES)), - EDIT_RECIPES(listOf(EDIT_RECIPES_PUBLIC_DATA, WRITE_FILE)), - EDIT_MATERIALS(listOf(VIEW_CATALOG, WRITE_FILE)), - EDIT_MATERIAL_TYPES(listOf(VIEW_CATALOG)), - EDIT_COMPANIES(listOf(VIEW_CATALOG)), - EDIT_USERS(listOf(VIEW_USERS)), - EDIT_CATALOG(listOf(EDIT_MATERIALS, EDIT_MATERIAL_TYPES, EDIT_COMPANIES)), + EDIT_RECIPES_PUBLIC_DATA(5, listOf(VIEW_RECIPES)), + EDIT_RECIPES(6, listOf(EDIT_RECIPES_PUBLIC_DATA, WRITE_FILE)), + EDIT_MATERIALS(7, listOf(VIEW_CATALOG, WRITE_FILE)), + EDIT_MATERIAL_TYPES(8, listOf(VIEW_CATALOG)), + EDIT_COMPANIES(9, listOf(VIEW_CATALOG)), + EDIT_USERS(10, listOf(VIEW_USERS)), + EDIT_CATALOG(11, listOf(EDIT_MATERIALS, EDIT_MATERIAL_TYPES, EDIT_COMPANIES)), - VIEW_TOUCH_UP_KITS, - EDIT_TOUCH_UP_KITS(listOf(VIEW_TOUCH_UP_KITS)), + VIEW_TOUCH_UP_KITS(12), + EDIT_TOUCH_UP_KITS(13, listOf(VIEW_TOUCH_UP_KITS)), - PRINT_MIXES(listOf(VIEW_RECIPES)), - ADD_TO_INVENTORY(listOf(VIEW_CATALOG)), - DEDUCT_FROM_INVENTORY(listOf(VIEW_RECIPES)), + PRINT_MIXES(14, listOf(VIEW_RECIPES)), + ADD_TO_INVENTORY(15, listOf(VIEW_CATALOG)), + DEDUCT_FROM_INVENTORY(16, listOf(VIEW_RECIPES)), ADMIN( + 17, listOf( EDIT_RECIPES, EDIT_CATALOG, @@ -44,58 +46,58 @@ enum class Permission( ), // deprecated permissions - VIEW_RECIPE(listOf(VIEW_RECIPES), true), - VIEW_MATERIAL(listOf(VIEW_CATALOG), true), - VIEW_MATERIAL_TYPE(listOf(VIEW_CATALOG), true), - VIEW_COMPANY(listOf(VIEW_CATALOG), true), - VIEW(listOf(VIEW_RECIPES, VIEW_CATALOG), true), - VIEW_EMPLOYEE(listOf(VIEW_USERS), true), - VIEW_EMPLOYEE_GROUP(listOf(VIEW_USERS), true), + VIEW_RECIPE(101, listOf(VIEW_RECIPES), true), + VIEW_MATERIAL(102, listOf(VIEW_CATALOG), true), + VIEW_MATERIAL_TYPE(103, listOf(VIEW_CATALOG), true), + VIEW_COMPANY(104, listOf(VIEW_CATALOG), true), + VIEW(105, listOf(VIEW_RECIPES, VIEW_CATALOG), true), + VIEW_EMPLOYEE(106, listOf(VIEW_USERS), true), + VIEW_EMPLOYEE_GROUP(107, listOf(VIEW_USERS), true), - EDIT_RECIPE(listOf(EDIT_RECIPES), true), - EDIT_MATERIAL(listOf(EDIT_MATERIALS), true), - EDIT_MATERIAL_TYPE(listOf(EDIT_MATERIAL_TYPES), true), - EDIT_COMPANY(listOf(EDIT_COMPANIES), true), - EDIT(listOf(EDIT_RECIPES, EDIT_CATALOG), true), - EDIT_EMPLOYEE(listOf(EDIT_USERS), true), - EDIT_EMPLOYEE_PASSWORD(listOf(EDIT_USERS), true), - EDIT_EMPLOYEE_GROUP(listOf(EDIT_USERS), true), + EDIT_RECIPE(108, listOf(EDIT_RECIPES), true), + EDIT_MATERIAL(109, listOf(EDIT_MATERIALS), true), + EDIT_MATERIAL_TYPE(110, listOf(EDIT_MATERIAL_TYPES), true), + EDIT_COMPANY(111, listOf(EDIT_COMPANIES), true), + EDIT(112, listOf(EDIT_RECIPES, EDIT_CATALOG), true), + EDIT_EMPLOYEE(113, listOf(EDIT_USERS), true), + EDIT_EMPLOYEE_PASSWORD(114, listOf(EDIT_USERS), true), + EDIT_EMPLOYEE_GROUP(115, listOf(EDIT_USERS), true), - REMOVE_FILE(listOf(WRITE_FILE), true), - GENERATE_TOUCH_UP_KIT(listOf(VIEW_TOUCH_UP_KITS), true), + REMOVE_FILE(116, listOf(WRITE_FILE), true), + GENERATE_TOUCH_UP_KIT(117, listOf(VIEW_TOUCH_UP_KITS), true), - REMOVE_RECIPES(listOf(EDIT_RECIPES, REMOVE_FILE), true), - REMOVE_MATERIALS(listOf(EDIT_MATERIALS, REMOVE_FILE), true), - REMOVE_MATERIAL_TYPES(listOf(EDIT_MATERIAL_TYPES), true), - REMOVE_COMPANIES(listOf(EDIT_COMPANIES), true), - REMOVE_USERS(listOf(EDIT_USERS), true), - REMOVE_CATALOG(listOf(REMOVE_MATERIALS, REMOVE_MATERIAL_TYPES, REMOVE_COMPANIES), true), + REMOVE_RECIPES(118, listOf(EDIT_RECIPES, REMOVE_FILE), true), + REMOVE_MATERIALS(119, listOf(EDIT_MATERIALS, REMOVE_FILE), true), + REMOVE_MATERIAL_TYPES(120, listOf(EDIT_MATERIAL_TYPES), true), + REMOVE_COMPANIES(121, listOf(EDIT_COMPANIES), true), + REMOVE_USERS(122, listOf(EDIT_USERS), true), + REMOVE_CATALOG(123, listOf(REMOVE_MATERIALS, REMOVE_MATERIAL_TYPES, REMOVE_COMPANIES), true), - REMOVE_RECIPE(listOf(REMOVE_RECIPES), true), - REMOVE_MATERIAL(listOf(REMOVE_MATERIALS), true), - REMOVE_MATERIAL_TYPE(listOf(REMOVE_MATERIAL_TYPES), true), - REMOVE_COMPANY(listOf(REMOVE_COMPANIES), true), - REMOVE(listOf(REMOVE_RECIPES, REMOVE_CATALOG), true), - REMOVE_EMPLOYEE(listOf(REMOVE_USERS), true), - REMOVE_EMPLOYEE_GROUP(listOf(REMOVE_USERS), true), + REMOVE_RECIPE(124, listOf(REMOVE_RECIPES), true), + REMOVE_MATERIAL(125, listOf(REMOVE_MATERIALS), true), + REMOVE_MATERIAL_TYPE(126, listOf(REMOVE_MATERIAL_TYPES), true), + REMOVE_COMPANY(127, listOf(REMOVE_COMPANIES), true), + REMOVE(128, listOf(REMOVE_RECIPES, REMOVE_CATALOG), true), + REMOVE_EMPLOYEE(129, listOf(REMOVE_USERS), true), + REMOVE_EMPLOYEE_GROUP(130, listOf(REMOVE_USERS), true), - SET_BROWSER_DEFAULT_GROUP(listOf(VIEW_USERS), true), + SET_BROWSER_DEFAULT_GROUP(131, listOf(VIEW_USERS), true), ; operator fun contains(permission: Permission): Boolean { return permission == this || impliedPermissions.any { permission in it } } -} -fun Permission.flat(): Iterable { - return mutableSetOf(this).apply { - impliedPermissions.forEach { - addAll(it.flat()) + fun flat(): Iterable { + return mutableSetOf(this).apply { + impliedPermissions.forEach { + addAll(it.flat()) + } } } -} -/** Converts the given [Permission] to a [GrantedAuthority]. */ -fun Permission.toAuthority(): GrantedAuthority { - return SimpleGrantedAuthority(name) + /** Converts the given permission to a [GrantedAuthority]. */ + fun toAuthority(): GrantedAuthority { + return SimpleGrantedAuthority(name) + } } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/ConfigurationController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/ConfigurationController.kt index d66b67b..c9f1e80 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/ConfigurationController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/ConfigurationController.kt @@ -4,7 +4,6 @@ import dev.fyloz.colorrecipesexplorer.logic.config.ConfigurationLogic import dev.fyloz.colorrecipesexplorer.model.ConfigurationBase import dev.fyloz.colorrecipesexplorer.model.ConfigurationDto import dev.fyloz.colorrecipesexplorer.model.account.Permission -import dev.fyloz.colorrecipesexplorer.model.account.toAuthority import dev.fyloz.colorrecipesexplorer.restartApplication import org.springframework.http.MediaType import org.springframework.security.access.prepost.PreAuthorize diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupTokenController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupTokenController.kt index b2adbd1..57d3811 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupTokenController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupTokenController.kt @@ -20,8 +20,7 @@ import javax.validation.Valid @PreAuthorizeAdmin @Profile("!emergency") class GroupTokenController( - private val groupTokenLogic: GroupTokenLogic, - private val jwtLogic: JwtLogic + private val groupTokenLogic: GroupTokenLogic, private val jwtLogic: JwtLogic ) { @GetMapping fun getAll() = ok(groupTokenLogic.getAll()) @@ -29,14 +28,6 @@ class GroupTokenController( @GetMapping("{id}") fun getById(@PathVariable id: String) = ok(groupTokenLogic.getById(id)) - // TODO Remove when group tokens will be fully implemented - @Deprecated("Only use for testing purposes") - @GetMapping("{id}/cookie") - fun addCookieForId(@PathVariable id: String, response: HttpServletResponse) { - val groupToken = groupTokenLogic.getById(id) - addGroupTokenCookie(response, groupToken) - } - @PostMapping fun save(@RequestBody @Valid dto: GroupTokenSaveDto, response: HttpServletResponse) = with(groupTokenLogic.save(dto)) { @@ -44,6 +35,16 @@ class GroupTokenController( created(Constants.ControllerPaths.GROUP_TOKEN, this, this.id) } + @PutMapping("{id}/enable") + fun enable(@PathVariable id: String) = noContent { + groupTokenLogic.enable(id) + } + + @PutMapping("{id}/disable") + fun disable(@PathVariable id: String) = noContent { + groupTokenLogic.disable(id) + } + @DeleteMapping("{id}") fun deleteById(@PathVariable id: String) = noContent { groupTokenLogic.deleteById(id) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/account/GroupService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/account/GroupService.kt index 5772c5f..773deb7 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/account/GroupService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/account/GroupService.kt @@ -4,7 +4,6 @@ import dev.fyloz.colorrecipesexplorer.config.annotations.ServiceComponent import dev.fyloz.colorrecipesexplorer.dtos.account.GroupDto import dev.fyloz.colorrecipesexplorer.model.account.Group import dev.fyloz.colorrecipesexplorer.model.account.Permission -import dev.fyloz.colorrecipesexplorer.model.account.flat import dev.fyloz.colorrecipesexplorer.repository.GroupRepository import dev.fyloz.colorrecipesexplorer.service.BaseService import dev.fyloz.colorrecipesexplorer.service.Service diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/account/GroupTokenService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/account/GroupTokenService.kt index 6cfdc3a..3cfb7c9 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/account/GroupTokenService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/account/GroupTokenService.kt @@ -44,5 +44,5 @@ class DefaultGroupTokenService(private val repository: GroupTokenRepository, pri GroupTokenDto(entity.id, entity.name, entity.isValid, groupService.toDto(entity.group)) override fun toEntity(dto: GroupTokenDto) = - GroupToken(dto.id, dto.name, dto.isValid, groupService.toEntity(dto.group)) + GroupToken(dto.id, dto.name, dto.enabled, groupService.toEntity(dto.group)) } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/account/UserService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/account/UserService.kt index 31c5a23..08a6c26 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/account/UserService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/account/UserService.kt @@ -5,7 +5,6 @@ import dev.fyloz.colorrecipesexplorer.dtos.account.GroupDto import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto import dev.fyloz.colorrecipesexplorer.model.account.Permission import dev.fyloz.colorrecipesexplorer.model.account.User -import dev.fyloz.colorrecipesexplorer.model.account.flat import dev.fyloz.colorrecipesexplorer.repository.UserRepository import dev.fyloz.colorrecipesexplorer.service.BaseService import dev.fyloz.colorrecipesexplorer.service.Service diff --git a/src/main/resources/application-debug.properties b/src/main/resources/application-debug.properties index 72fc330..6555358 100644 --- a/src/main/resources/application-debug.properties +++ b/src/main/resources/application-debug.properties @@ -1 +1 @@ -spring.jpa.show-sql=true \ No newline at end of file +spring.jpa.show-sql=false From ed0e5d89d3ebe294b791b52b37f886b48286f91d Mon Sep 17 00:00:00 2001 From: william Date: Thu, 5 May 2022 08:02:55 -0400 Subject: [PATCH 06/10] #30 Add tests for jwt logic and group token logic --- .../dtos/account/UserDto.kt | 4 +- .../logic/account/GroupTokenLogic.kt | 32 ++- .../logic/account/JwtLogic.kt | 2 - .../service/account/UserService.kt | 2 +- src/main/resources/application.properties | 2 +- .../logic/DefaultJwtLogicTest.kt | 122 +++++---- .../logic/account/DefaultGroupLogicTest.kt | 15 +- .../account/DefaultGroupTokenLogicTest.kt | 234 ++++++++++++++++++ .../logic/account/DefaultUserLogicTest.kt | 61 +---- 9 files changed, 334 insertions(+), 140 deletions(-) create mode 100644 src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultGroupTokenLogicTest.kt diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/UserDto.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/UserDto.kt index 6216a76..0c94b1a 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/UserDto.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/UserDto.kt @@ -48,9 +48,7 @@ data class UserSaveDto( val permissions: List, - @field:JsonIgnore val isSystemUser: Boolean = false, - - @field:JsonIgnore val isDefaultGroupUser: Boolean = false + @field:JsonIgnore val isSystemUser: Boolean = false ) data class UserUpdateDto( diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupTokenLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupTokenLogic.kt index f689530..3350224 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupTokenLogic.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupTokenLogic.kt @@ -10,6 +10,7 @@ import dev.fyloz.colorrecipesexplorer.logic.BaseLogic import dev.fyloz.colorrecipesexplorer.service.account.GroupTokenService import java.util.* import javax.annotation.PostConstruct +import kotlin.collections.HashSet interface GroupTokenLogic { fun isDisabled(id: String): Boolean @@ -23,13 +24,15 @@ interface GroupTokenLogic { } @LogicComponent -class DefaultGroupTokenLogic(private val service: GroupTokenService, private val groupLogic: GroupLogic) : +class DefaultGroupTokenLogic( + private val service: GroupTokenService, + private val groupLogic: GroupLogic, + private val enabledTokensCache: HashSet = hashSetOf() // In constructor for unit testing +) : GroupTokenLogic { private val typeName = Constants.ModelNames.GROUP_TOKEN private val typeNameLowerCase = typeName.lowercase() - private val enabledTokensCache = hashSetOf() - @PostConstruct fun initEnabledTokensCache() { val tokensIds = getAll().filter { it.enabled }.map { it.id.toString() } @@ -45,8 +48,10 @@ class DefaultGroupTokenLogic(private val service: GroupTokenService, private val 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 token = GroupTokenDto( - generateUniqueUUIDForName(dto.name), dto.name, true, groupLogic.getById(dto.groupId) + id, dto.name, true, groupLogic.getById(dto.groupId) ) val savedToken = service.save(token) @@ -56,11 +61,15 @@ class DefaultGroupTokenLogic(private val service: GroupTokenService, private val } override fun enable(id: String) = setEnabled(id, true).also { - enabledTokensCache.add(id) + if (isDisabled(id)) { + enabledTokensCache.add(id) + } } override fun disable(id: String) = setEnabled(id, false).also { - enabledTokensCache.remove(id) + if (!isDisabled(id)) { + enabledTokensCache.remove(id) + } } override fun deleteById(id: String) { @@ -72,17 +81,6 @@ class DefaultGroupTokenLogic(private val service: GroupTokenService, private val service.save(this.copy(enabled = enabled)) } - private fun generateUniqueUUIDForName(name: String): UUID { - var id = generateUUIDForName(name) - - // UUIDs do not guarantee that collisions can't happen - while (service.existsById(id)) { - id = generateUUIDForName(name) - } - - return id - } - private fun generateUUIDForName(name: String) = UUID.nameUUIDFromBytes(name.toByteArray()) private fun throwIfNameAlreadyExists(name: String) { diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/JwtLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/JwtLogic.kt index 4b0a971..1cda7b1 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/JwtLogic.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/JwtLogic.kt @@ -15,8 +15,6 @@ import org.springframework.stereotype.Service import java.time.Instant import java.util.* -const val jwtClaimUser = "user" - interface JwtLogic { /** Build a JWT for the given [userDetails]. */ fun buildUserJwt(userDetails: UserDetails): String diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/account/UserService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/account/UserService.kt index 08a6c26..299128d 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/account/UserService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/account/UserService.kt @@ -86,6 +86,6 @@ class DefaultUserService(repository: UserRepository, private val groupService: G return perms + groupService.flattenPermissions(user.group) } - return perms + return perms.distinctBy { it.id } } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 8a271b9..18c7f3f 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -30,4 +30,4 @@ spring.jackson.deserialization.fail-on-null-for-primitives=true spring.jackson.default-property-inclusion=non_null spring.profiles.active=@spring.profiles.active@ -spring.sql.init.continue-on-error=true \ No newline at end of file +spring.sql.init.continue-on-error=true diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultJwtLogicTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultJwtLogicTest.kt index 08d784c..37e98ff 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultJwtLogicTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultJwtLogicTest.kt @@ -1,100 +1,122 @@ package dev.fyloz.colorrecipesexplorer.logic import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.fasterxml.jackson.module.kotlin.readValue import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties import dev.fyloz.colorrecipesexplorer.dtos.account.UserDetails import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto import dev.fyloz.colorrecipesexplorer.logic.account.DefaultJwtLogic -import dev.fyloz.colorrecipesexplorer.logic.account.jwtClaimUser -import dev.fyloz.colorrecipesexplorer.utils.base64encode -import dev.fyloz.colorrecipesexplorer.utils.isAround -import io.jsonwebtoken.Jwts -import io.jsonwebtoken.jackson.io.JacksonDeserializer +import dev.fyloz.colorrecipesexplorer.model.account.Permission import io.mockk.clearAllMocks import io.mockk.spyk import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test -import java.time.Instant +import java.util.* import kotlin.test.assertEquals -import kotlin.test.assertTrue +import kotlin.test.assertFalse class DefaultJwtLogicTest { private val objectMapper = jacksonObjectMapper() private val securityProperties = CreSecurityProperties().apply { - jwtSecret = "XRRm7OflmFuCrOB2Xvmfsercih9DCKom" + jwtSecret = "exBwMbD9Jw7YF7HYpwXQjcsPf4SrRSSF5YTvgbj0" jwtDuration = 1000000L } - private val jwtParser by lazy { - Jwts.parserBuilder() - .deserializeJsonWith(JacksonDeserializer>(objectMapper)) - .setSigningKey(securityProperties.jwtSecret.base64encode()) - .build() - } - private val jwtService = spyk(DefaultJwtLogic(objectMapper, securityProperties)) + private val jwtLogic = spyk(DefaultJwtLogic(objectMapper, securityProperties)) - private val user = UserDto(0L, "Unit test", "User", "", null, listOf()) + private val permissions = listOf(Permission.VIEW_RECIPES, Permission.READ_FILE, Permission.VIEW_CATALOG) + private val user = UserDto(999L, "Unit test", "User", "", null, permissions) + private val userDetails = UserDetails(user) + + private val userJwt = "eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiI5OTkiLCJleHAiOjE2Njk2MTc1MDcsInBlcm1zIjoiWzIsMCwzXSIsInR5cGUiOjB9.bg8hbTRsWOcx4te3L0vi8WNPXWLZO-heS7bNsO_FBpkRPy4l-MtdLOa6hx_-pXbZ" + private val groupTokenJwt = "eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiJhMDIyZWU3YS03NGY5LTNjYTYtYmYwZC04ZTg3OWE2NjRhOWUifQ.VaRqPJ30h8WUACPf8wVrjaxINQcc9xnbzGOcMesW_PbeN9rEGzgkgFEuV4TRGlOr" + private val groupTokenId = UUID.nameUUIDFromBytes("Unit test token".toByteArray()) @AfterEach internal fun afterEach() { clearAllMocks() } - private fun withParsedUserOutputDto(jwt: String, test: (UserDto) -> Unit) { - val serializedUser = jwtParser.parseClaimsJws(jwt) - .body.get(jwtClaimUser, String::class.java) + @Test + fun buildUserJwt_normalBehavior_buildJwtWithValidSubject() { + // Arrange + // Act + val jwt = jwtLogic.buildUserJwt(userDetails) - test(objectMapper.readValue(serializedUser)) + // Assert + val parsedJwt = jwtLogic.parseUserJwt(jwt) + assertEquals(user.id.toString(), parsedJwt.id) } @Test - fun buildJwt_userDetails_normalBehavior_returnsJwtStringWithValidUser() { - val userDetails = UserDetails(user) + fun buildUserJwt_normalBehavior_buildJwtWithValidType() { + // Arrange + // Act + val jwt = jwtLogic.buildUserJwt(userDetails) - val builtJwt = jwtService.buildUserJwt(userDetails) - - withParsedUserOutputDto(builtJwt) { parsedUser -> - assertEquals(user, parsedUser) - } + // Assert + val parsedJwt = jwtLogic.parseUserJwt(jwt) + assertFalse(parsedJwt.isGroup) } @Test - fun buildJwt_user_normalBehavior_returnsJwtStringWithValidUser() { - val builtJwt = jwtService.buildUserJwt(user) + fun buildUserJwt_normalBehavior_buildJwtWithValidPermissions() { + // Arrange + // Act + val jwt = jwtLogic.buildUserJwt(userDetails) - withParsedUserOutputDto(builtJwt) { parsedUser -> - assertEquals(user, parsedUser) - } + // Assert + val parsedJwt = jwtLogic.parseUserJwt(jwt) + assertEquals(userDetails.authorities, parsedJwt.authorities) } @Test - fun buildJwt_user_normalBehavior_returnsJwtStringWithValidSubject() { - val builtJwt = jwtService.buildUserJwt(user) - val jwtSubject = jwtParser.parseClaimsJws(builtJwt).body.subject + fun buildGroupTokenIdJwt_normalBehavior_buildJwtWithValidSubject(){ + // Arrange + // Act + val jwt = jwtLogic.buildGroupTokenIdJwt(groupTokenId) - assertEquals(user.id.toString(), jwtSubject) + // Assert + val parsedGroupId = jwtLogic.parseGroupTokenIdJwt(jwt) + assertEquals(groupTokenId, parsedGroupId) } @Test - fun buildJwt_user_returnsJwtWithValidExpirationDate() { - val jwtExpectedExpirationDate = Instant.now().plusSeconds(securityProperties.jwtDuration) + fun parseUserJwt_normalBehavior_returnsUserWithValidId() { + // Arrange + // Act + val user = jwtLogic.parseUserJwt(userJwt) - val builtJwt = jwtService.buildUserJwt(user) - val jwtExpiration = jwtParser.parseClaimsJws(builtJwt) - .body.expiration.toInstant() - - // Check if it's between 1 second - assertTrue { jwtExpiration.isAround(jwtExpectedExpirationDate) } + // Assert + assertEquals(userDetails.id, user.id) } - // parseJwt() + @Test + fun parseUserJwt_normalBehavior_returnsUserWithValidType() { + // Arrange + // Act + val user = jwtLogic.parseUserJwt(userJwt) + + // Assert + assertFalse(user.isGroup) + } @Test - fun parseJwt_normalBehavior_returnsExpectedUser() { - val jwt = jwtService.buildUserJwt(user) - val parsedUser = jwtService.parseUserJwt(jwt) + fun parseUserJwt_normalBehavior_returnsUserWithValidPermissions() { + // Arrange + // Act + val user = jwtLogic.parseUserJwt(userJwt) - assertEquals(user, parsedUser) + // Assert + assertEquals(userDetails.authorities, user.authorities) + } + + @Test + fun parseGroupTokenId_normalBehavior_returnsValidGroupTokenId() { + // Arrange + // Act + val parsedGroupTokenId = jwtLogic.parseGroupTokenIdJwt(groupTokenJwt) + + // Assert + assertEquals(groupTokenId, parsedGroupTokenId) } } diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultGroupLogicTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultGroupLogicTest.kt index 2c9ea17..83d3a3e 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultGroupLogicTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultGroupLogicTest.kt @@ -23,8 +23,7 @@ class DefaultGroupLogicTest { } private val userLogicMock = mockk { every { getAllByGroup(any()) } returns listOf() - every { getById(any(), any(), any()) } returns user - every { getDefaultGroupUser(any()) } returns user + every { getById(any(), any()) } returns user every { deleteById(any()) } just runs } @@ -69,16 +68,4 @@ class DefaultGroupLogicTest { // Assert assertThrows { groupLogic.update(group) } } - - @Test - fun deleteById_normalBehavior_callsDeleteByIdInUserLogicWithDefaultGroupUserId() { - // Arrange - // Act - groupLogic.deleteById(group.id) - - // Assert - verify { - userLogicMock.deleteById(group.defaultGroupUserId) - } - } } diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultGroupTokenLogicTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultGroupTokenLogicTest.kt new file mode 100644 index 0000000..25f8182 --- /dev/null +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultGroupTokenLogicTest.kt @@ -0,0 +1,234 @@ +package dev.fyloz.colorrecipesexplorer.logic.account + +import dev.fyloz.colorrecipesexplorer.dtos.account.GroupDto +import dev.fyloz.colorrecipesexplorer.dtos.account.GroupTokenDto +import dev.fyloz.colorrecipesexplorer.dtos.account.GroupTokenSaveDto +import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException +import dev.fyloz.colorrecipesexplorer.exception.NotFoundException +import dev.fyloz.colorrecipesexplorer.service.account.GroupTokenService +import io.mockk.* +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import java.util.* +import kotlin.test.* + +class DefaultGroupTokenLogicTest { + private val groupTokenServiceMock = mockk() + private val groupLogicMock = mockk() + + private val enabledTokenCache = hashSetOf() + + private val groupTokenLogic = spyk(DefaultGroupTokenLogic(groupTokenServiceMock, groupLogicMock, 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 groupTokenSaveDto = GroupTokenSaveDto(groupTokenName, group.id) + + @AfterEach + fun afterEach() { + clearAllMocks() + enabledTokenCache.clear() + } + + @Test + fun isDisabled_groupTokenIdInCache_returnsFalse() { + // Arrange + enabledTokenCache.add(groupTokenIdStr) + + // Act + val disabled = groupTokenLogic.isDisabled(groupTokenIdStr) + + // Assert + assertFalse(disabled) + } + + @Test + fun isDisabled_groupTokenIdNotInCache_returnsTrue() { + // Arrange + // Act + val disabled = groupTokenLogic.isDisabled(groupTokenIdStr) + + // Assert + assertTrue(disabled) + } + + @Test + fun getAll_normalBehavior_returnsFromService() { + // Arrange + val expectedGroupTokens = listOf(groupToken) + + every { groupTokenServiceMock.getAll() } returns expectedGroupTokens + + // Act + val actualGroupTokens = groupTokenLogic.getAll() + + // Assert + assertEquals(expectedGroupTokens, actualGroupTokens) + } + + @Test + fun getById_string_normalBehavior_callsGetByIdWithValidUUID() { + // Arrange + every { groupTokenLogic.getById(any()) } returns groupToken + + // Act + groupTokenLogic.getById(groupTokenIdStr) + + // Assert + verify { + groupTokenLogic.getById(groupTokenId) + } + } + + @Test + fun getById_uuid_normalBehavior_returnsFromService() { + // Arrange + every { groupTokenServiceMock.getById(any()) } returns groupToken + + // Act + val actualGroupToken = groupTokenLogic.getById(groupTokenId) + + // Assert + assertSame(groupToken, actualGroupToken) + } + + @Test + fun getById_uuid_notFound_throwsNotFoundException() { + // Arrange + every { groupTokenServiceMock.getById(any()) } returns null + + // Act + // Assert + assertThrows { groupTokenLogic.getById(groupTokenId) } + } + + @Test + fun save_normalBehavior_callsSaveInService() { + // Arrange + every { groupTokenServiceMock.existsByName(any()) } returns false + every { groupTokenServiceMock.save(any()) } returns groupToken + every { groupLogicMock.getById(any()) } returns group + + // Act + groupTokenLogic.save(groupTokenSaveDto) + + // Assert + verify { + groupTokenServiceMock.save(groupToken) + } + } + + @Test + fun save_normalBehavior_addsIdToEnabledTokensCache() { + // Arrange + every { groupTokenServiceMock.existsByName(any()) } returns false + every { groupTokenServiceMock.save(any()) } returns groupToken + every { groupLogicMock.getById(any()) } returns group + + // Act + groupTokenLogic.save(groupTokenSaveDto) + + // Assert + assertContains(enabledTokenCache, groupTokenIdStr) + } + + @Test + fun save_nameAlreadyExists_throwsAlreadyExistsException() { + // Arrange + every { groupTokenServiceMock.existsByName(any()) } returns true + + // Act + // Assert + assertThrows { groupTokenLogic.save(groupTokenSaveDto) } + } + + @Test + fun enable_normalBehavior_savesTokenInService() { + // Arrange + every { groupTokenServiceMock.save(any()) } returns groupToken + every { groupTokenLogic.getById(any()) } returns groupToken + + // Act + groupTokenLogic.enable(groupTokenIdStr) + + // Assert + verify { + groupTokenServiceMock.save(match { + it.id == groupTokenId && it.name == groupTokenName && it.enabled + }) + } + } + + @Test + fun enable_normalBehavior_addsIdToEnabledTokensCache() { + // Arrange + every { groupTokenServiceMock.save(any()) } returns groupToken + every { groupTokenLogic.getById(any()) } returns groupToken + + // Act + groupTokenLogic.enable(groupTokenIdStr) + + // Assert + assertContains(enabledTokenCache, groupTokenIdStr) + } + + @Test + fun disable_normalBehavior_savesTokenInService() { + // Arrange + every { groupTokenServiceMock.save(any()) } returns groupToken + every { groupTokenLogic.getById(any()) } returns groupToken + + // Act + groupTokenLogic.disable(groupTokenIdStr) + + // Assert + verify { + groupTokenServiceMock.save(match { + it.id == groupTokenId && it.name == groupTokenName && !it.enabled + }) + } + } + + @Test + fun disable_normalBehavior_removesIdFromEnabledTokensCache() { + // Arrange + every { groupTokenServiceMock.save(any()) } returns groupToken + every { groupTokenLogic.getById(any()) } returns groupToken + + // Act + groupTokenLogic.disable(groupTokenIdStr) + + // Assert + assertFalse(enabledTokenCache.contains(groupTokenIdStr)) + } + + @Test + fun deleteById_normalBehavior_callsService() { + // Arrange + every { groupTokenServiceMock.deleteById(any()) } just runs + + // Act + groupTokenLogic.deleteById(groupTokenIdStr) + + // Assert + verify { + groupTokenServiceMock.deleteById(groupTokenId) + } + } + + @Test + fun deleteById_normalBehavior_removesIdFromEnabledTokensCache() { + // Arrange + every { groupTokenServiceMock.deleteById(any()) } just runs + + // Act + groupTokenLogic.deleteById(groupTokenIdStr) + + // Assert + assertFalse(enabledTokenCache.contains(groupTokenIdStr)) + } +} diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultUserLogicTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultUserLogicTest.kt index d288021..9e6dc89 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultUserLogicTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultUserLogicTest.kt @@ -22,11 +22,10 @@ class DefaultUserLogicTest { private val userServiceMock = mockk { every { existsById(any()) } returns false every { existsByFirstNameAndLastName(any(), any(), any()) } returns false - every { getAll(any(), any()) } returns listOf() + every { getAll(any()) } returns listOf() every { getAllByGroup(any()) } returns listOf() - every { getById(any(), any(), any()) } returns user + every { getById(any(), any()) } returns user every { getByFirstNameAndLastName(any(), any()) } returns user - every { getDefaultGroupUser(any()) } returns user } private val groupLogicMock = mockk { every { getById(any()) } returns group @@ -44,8 +43,7 @@ class DefaultUserLogicTest { user.password, null, user.permissions, - user.isSystemUser, - user.isDefaultGroupUser + user.isSystemUser ) private val userUpdateDto = UserUpdateDto(user.id, user.firstName, user.lastName, null, listOf()) @@ -62,7 +60,7 @@ class DefaultUserLogicTest { // Assert verify { - userServiceMock.getAll(isSystemUser = false, isDefaultGroupUser = false) + userServiceMock.getAll(isSystemUser = false) } confirmVerified(userServiceMock) } @@ -88,7 +86,7 @@ class DefaultUserLogicTest { // Assert verify { - userLogic.getById(user.id, isSystemUser = false, isDefaultGroupUser = false) + userLogic.getById(user.id, isSystemUser = false) } } @@ -96,11 +94,11 @@ class DefaultUserLogicTest { fun getById_normalBehavior_callsGetByIdInService() { // Arrange // Act - userLogic.getById(user.id, isSystemUser = false, isDefaultGroupUser = true) + userLogic.getById(user.id, isSystemUser = false) // Assert verify { - userServiceMock.getById(user.id, isSystemUser = false, isDefaultGroupUser = true) + userServiceMock.getById(user.id, isSystemUser = false) } confirmVerified(userServiceMock) } @@ -108,54 +106,13 @@ class DefaultUserLogicTest { @Test fun getById_notFound_throwsNotFoundException() { // Arrange - every { userServiceMock.getById(any(), any(), any()) } returns null + every { userServiceMock.getById(any(), any()) } returns null // Act // Assert assertThrows { userLogic.getById(user.id) } } - @Test - fun getDefaultGroupUser_normalBehavior_callsGetDefaultGroupUserInService() { - // Arrange - // Act - userLogic.getDefaultGroupUser(group) - - // Assert - verify { - userServiceMock.getDefaultGroupUser(group) - } - confirmVerified(userServiceMock) - } - - @Test - fun getDefaultGroupUser_notFound_throwsNotFoundException() { - // Arrange - every { userServiceMock.getDefaultGroupUser(any()) } returns null - - // Act - // Assert - assertThrows { userLogic.getDefaultGroupUser(group) } - } - - @Test - fun saveDefaultGroupUser_normalBehavior_callsSaveWithValidSaveDto() { - // Arrange - every { userLogic.save(any()) } returns user - - val expectedSaveDto = UserSaveDto( - group.defaultGroupUserId, group.name, "User", group.name, group.id, listOf(), isDefaultGroupUser = true - ) - - // Act - userLogic.saveDefaultGroupUser(group) - - // Assert - verify { - userLogic.save(expectedSaveDto) - } - } - @Test fun save_dto_normalBehavior_callsSaveWithValidUser() { // Arrange @@ -208,7 +165,7 @@ class DefaultUserLogicTest { @Test fun update_dto_normalBehavior_callsUpdateWithValidUser() { // Arrange - every { userLogic.getById(any(), any(), any()) } returns user + every { userLogic.getById(any(), any()) } returns user every { userLogic.update(any()) } returns user // Act From d02faf7bceab5c530abf34bd974c5807ad9320cd Mon Sep 17 00:00:00 2001 From: william Date: Fri, 6 May 2022 07:43:36 -0400 Subject: [PATCH 07/10] #30 Add tests for jwt logic and group token logic --- build.gradle.kts | 4 ---- src/main/kotlin/dev/fyloz/colorrecipesexplorer/Constants.kt | 1 + .../fyloz/colorrecipesexplorer/rest/account/UserController.kt | 4 ++-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 1325653..c9e5674 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -106,10 +106,6 @@ tasks.withType() { tasks.withType().all { kotlinOptions { jvmTarget = JavaVersion.VERSION_17.toString() - freeCompilerArgs = listOf( - "-Xopt-in=kotlin.contracts.ExperimentalContracts", - "-Xinline-classes" - ) } } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/Constants.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/Constants.kt index 6478c24..5bb0754 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/Constants.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/Constants.kt @@ -21,6 +21,7 @@ object Constants { const val LOGIN = "$BASE_PATH/account/login" const val GROUP_LOGIN = "$BASE_PATH/account/login/group" + const val LOGOUT = "$BASE_PATH/account/logout" } object CookieNames { diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/UserController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/UserController.kt index d611e82..13414d2 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/UserController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/UserController.kt @@ -78,10 +78,10 @@ class UserController(private val userLogic: UserLogic) { } @RestController -@RequestMapping("api") +@RequestMapping(Constants.ControllerPaths.LOGOUT) @Profile("!emergency") class LogoutController(private val userLogic: UserLogic) { - @GetMapping("logout") + @GetMapping @PreAuthorize("isFullyAuthenticated()") fun logout(request: HttpServletRequest) = ok { From 6b2d7dfa03ca6ac1fc546972b971a91cb7c60a3a Mon Sep 17 00:00:00 2001 From: william Date: Fri, 13 May 2022 23:01:46 -0400 Subject: [PATCH 08/10] #30 Add endpoint to get current group token --- .../config/security/SecurityConfig.kt | 2 +- .../filters/GroupTokenAuthenticationFilter.kt | 14 ++++---------- .../logic/account/GroupTokenLogic.kt | 18 ++++++++++++++++-- .../rest/account/GroupTokenController.kt | 8 ++++++++ 4 files changed, 29 insertions(+), 13 deletions(-) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt index fdb2984..12661ad 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt @@ -88,7 +88,7 @@ abstract class BaseSecurityConfig( .and() .csrf().disable() .addFilterBefore( - GroupTokenAuthenticationFilter(jwtLogic, securityProperties, authManager), + GroupTokenAuthenticationFilter(jwtLogic, securityProperties, groupTokenLogic, authManager), BasicAuthenticationFilter::class.java ) .addFilterBefore( diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/GroupTokenAuthenticationFilter.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/GroupTokenAuthenticationFilter.kt index d2c7601..f114d8a 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/GroupTokenAuthenticationFilter.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/GroupTokenAuthenticationFilter.kt @@ -4,27 +4,24 @@ import dev.fyloz.colorrecipesexplorer.Constants import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties import dev.fyloz.colorrecipesexplorer.config.security.GroupAuthenticationToken import dev.fyloz.colorrecipesexplorer.dtos.account.UserDetails +import dev.fyloz.colorrecipesexplorer.logic.account.GroupTokenLogic import dev.fyloz.colorrecipesexplorer.logic.account.JwtLogic -import dev.fyloz.colorrecipesexplorer.utils.parseBearer import org.springframework.security.authentication.AuthenticationManager import org.springframework.security.authentication.BadCredentialsException import org.springframework.security.core.Authentication -import org.springframework.web.util.WebUtils import javax.servlet.http.HttpServletRequest import javax.servlet.http.HttpServletResponse class GroupTokenAuthenticationFilter( jwtLogic: JwtLogic, securityProperties: CreSecurityProperties, + private val groupTokenLogic: GroupTokenLogic, private val authManager: AuthenticationManager ) : JwtAuthenticationFilter(Constants.ControllerPaths.GROUP_LOGIN, securityProperties, jwtLogic) { override fun attemptAuthentication(request: HttpServletRequest, response: HttpServletResponse): Authentication { - val groupTokenCookie = getGroupTokenCookie(request) + val groupTokenId = groupTokenLogic.getIdForRequest(request) ?: throw BadCredentialsException("Required group token cookie was not present") - val jwt = parseBearer(groupTokenCookie.value) - val groupTokenId = jwtLogic.parseGroupTokenIdJwt(jwt) - logger.debug("Login attempt for group token $groupTokenId") return authManager.authenticate(GroupAuthenticationToken(groupTokenId)) } @@ -32,7 +29,4 @@ class GroupTokenAuthenticationFilter( override fun afterSuccessfulAuthentication(userDetails: UserDetails) { logger.info("Successful login for group id '${userDetails.group!!.id}' using token '${userDetails.id}' (${userDetails.username})") } - - private fun getGroupTokenCookie(request: HttpServletRequest) = - WebUtils.getCookie(request, Constants.CookieNames.GROUP_TOKEN) -} \ No newline at end of file +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupTokenLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupTokenLogic.kt index 3350224..a3b96d9 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupTokenLogic.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupTokenLogic.kt @@ -8,15 +8,18 @@ import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException import dev.fyloz.colorrecipesexplorer.exception.NotFoundException import dev.fyloz.colorrecipesexplorer.logic.BaseLogic import dev.fyloz.colorrecipesexplorer.service.account.GroupTokenService +import dev.fyloz.colorrecipesexplorer.utils.parseBearer +import org.springframework.web.util.WebUtils import java.util.* import javax.annotation.PostConstruct -import kotlin.collections.HashSet +import javax.servlet.http.HttpServletRequest interface GroupTokenLogic { fun isDisabled(id: String): Boolean fun getAll(): Collection fun getById(id: String): GroupTokenDto fun getById(id: UUID): GroupTokenDto + fun getIdForRequest(request: HttpServletRequest): UUID? fun save(dto: GroupTokenSaveDto): GroupTokenDto fun enable(id: String): GroupTokenDto fun disable(id: String): GroupTokenDto @@ -27,6 +30,7 @@ interface GroupTokenLogic { class DefaultGroupTokenLogic( private val service: GroupTokenService, private val groupLogic: GroupLogic, + private val jwtLogic: JwtLogic, private val enabledTokensCache: HashSet = hashSetOf() // In constructor for unit testing ) : GroupTokenLogic { @@ -42,9 +46,16 @@ class DefaultGroupTokenLogic( override fun isDisabled(id: String) = !enabledTokensCache.contains(id) override fun getAll() = service.getAll() override fun getById(id: String) = getById(UUID.fromString(id)) - 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 jwt = parseBearer(groupTokenCookie.value) + return jwtLogic.parseGroupTokenIdJwt(jwt) + } + override fun save(dto: GroupTokenSaveDto): GroupTokenDto { throwIfNameAlreadyExists(dto.name) @@ -89,6 +100,9 @@ class DefaultGroupTokenLogic( } } + private fun getGroupTokenCookie(request: HttpServletRequest) = + WebUtils.getCookie(request, Constants.CookieNames.GROUP_TOKEN) + private fun notFoundException(identifierName: String = BaseLogic.ID_IDENTIFIER_NAME, value: Any) = NotFoundException( typeNameLowerCase, diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupTokenController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupTokenController.kt index 57d3811..c8221b6 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupTokenController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupTokenController.kt @@ -11,7 +11,9 @@ import dev.fyloz.colorrecipesexplorer.rest.noContent import dev.fyloz.colorrecipesexplorer.rest.ok import dev.fyloz.colorrecipesexplorer.utils.addCookie import org.springframework.context.annotation.Profile +import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* +import javax.servlet.http.HttpServletRequest import javax.servlet.http.HttpServletResponse import javax.validation.Valid @@ -25,6 +27,12 @@ class GroupTokenController( @GetMapping fun getAll() = ok(groupTokenLogic.getAll()) + @GetMapping("current") + fun getCurrent(request: HttpServletRequest): ResponseEntity { + val id = groupTokenLogic.getIdForRequest(request) ?: return ok(null) + return ok(groupTokenLogic.getById(id)) + } + @GetMapping("{id}") fun getById(@PathVariable id: String) = ok(groupTokenLogic.getById(id)) From 69c5d86d45194da0db89ae477e472517289f6e8e Mon Sep 17 00:00:00 2001 From: william Date: Sat, 14 May 2022 21:19:56 -0400 Subject: [PATCH 09/10] #30 Generalize group token cookie path to allow the cookie in the group token controller --- .../dev/fyloz/colorrecipesexplorer/Constants.kt | 11 ++++++----- .../rest/account/GroupTokenController.kt | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/Constants.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/Constants.kt index 5bb0754..66e863d 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/Constants.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/Constants.kt @@ -6,22 +6,23 @@ object Constants { object ControllerPaths { const val BASE_PATH = "/api" + const val ACCOUNT_BASE_PATH = "$BASE_PATH/account" const val COMPANY = "$BASE_PATH/company" const val GROUP_TOKEN = "$BASE_PATH/account/group/token" const val FILE = "$BASE_PATH/file" - const val GROUP = "$BASE_PATH/account/group" const val INVENTORY = "$BASE_PATH/inventory" const val MATERIAL = "$BASE_PATH/material" const val MATERIAL_TYPE = "$BASE_PATH/materialtype" const val MIX = "$BASE_PATH/recipe/mix" const val RECIPE = "$BASE_PATH/recipe" const val TOUCH_UP_KIT = "$BASE_PATH/touchupkit" - const val USER = "$BASE_PATH/account/user" - const val LOGIN = "$BASE_PATH/account/login" - const val GROUP_LOGIN = "$BASE_PATH/account/login/group" - const val LOGOUT = "$BASE_PATH/account/logout" + const val GROUP = "$ACCOUNT_BASE_PATH/group" + const val GROUP_LOGIN = "$ACCOUNT_BASE_PATH/login/group" + const val LOGIN = "$ACCOUNT_BASE_PATH/login" + const val LOGOUT = "$ACCOUNT_BASE_PATH/logout" + const val USER = "$ACCOUNT_BASE_PATH/user" } object CookieNames { diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupTokenController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupTokenController.kt index c8221b6..828192a 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupTokenController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupTokenController.kt @@ -74,6 +74,6 @@ class GroupTokenController( companion object { private const val GROUP_TOKEN_COOKIE_HTTP_ONLY = true private const val GROUP_TOKEN_COOKIE_SAME_SITE = true - private const val GROUP_TOKEN_COOKIE_PATH = Constants.ControllerPaths.GROUP_LOGIN + private const val GROUP_TOKEN_COOKIE_PATH = Constants.ControllerPaths.ACCOUNT_BASE_PATH } } From b235b5a84502f320490748f0bfc5bbf4d2ed3a96 Mon Sep 17 00:00:00 2001 From: william Date: Fri, 22 Jul 2022 08:46:16 -0400 Subject: [PATCH 10/10] #30 Random group token UUIDs to prevent critical security problem. --- .../filters/JwtAuthenticationFilter.kt | 4 +- .../dtos/account/GroupTokenDto.kt | 2 + .../logic/account/GroupTokenLogic.kt | 26 +++--- .../model/account/GroupToken.kt | 3 + .../repository/AccountRepository.kt | 10 ++- .../service/account/GroupTokenService.kt | 11 ++- .../account/DefaultGroupTokenLogicTest.kt | 80 +++++++++++++++++-- 7 files changed, 109 insertions(+), 27 deletions(-) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthenticationFilter.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthenticationFilter.kt index 08fe21c..0653354 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthenticationFilter.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthenticationFilter.kt @@ -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 } -} \ No newline at end of file +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/GroupTokenDto.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/GroupTokenDto.kt index 6277fdc..f507e8c 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/GroupTokenDto.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/GroupTokenDto.kt @@ -10,6 +10,8 @@ data class GroupTokenDto( val enabled: Boolean, + val isDeleted: Boolean, + val group: GroupDto ) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupTokenLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupTokenLogic.kt index a3b96d9..b956a1a 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupTokenLogic.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupTokenLogic.kt @@ -32,8 +32,7 @@ class DefaultGroupTokenLogic( private val groupLogic: GroupLogic, private val jwtLogic: JwtLogic, private val enabledTokensCache: HashSet = 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)) { diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/account/GroupToken.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/account/GroupToken.kt index 0820672..7111906 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/account/GroupToken.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/account/GroupToken.kt @@ -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 diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/AccountRepository.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/AccountRepository.kt index e530d45..dee225e 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/AccountRepository.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/AccountRepository.kt @@ -39,6 +39,12 @@ interface GroupRepository : JpaRepository { @Repository interface GroupTokenRepository : JpaRepository { - /** 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 + + /** Finds the group token with the given [id] if it is not deleted. */ + fun findByIdAndIsDeletedIsFalse(id: UUID): GroupToken? } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/account/GroupTokenService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/account/GroupTokenService.kt index 3cfb7c9..d780b22 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/account/GroupTokenService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/account/GroupTokenService.kt @@ -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)) } diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultGroupTokenLogicTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultGroupTokenLogicTest.kt index 25f8182..03b66e0 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultGroupTokenLogicTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultGroupTokenLogicTest.kt @@ -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() private val groupLogicMock = mockk() + private val jwtLogicMock = mockk() private val enabledTokenCache = hashSetOf() - 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 { groupTokenLogic.getById(groupTokenId) } } + @Test + fun getIdForRequest_normalBehavior_returnsGroupTokenIdFromRequest() { + // Arrange + val request = mockk() + val cookie = mockk { + 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()) } 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()) } 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? = null, block: () -> Unit) { + mockkStatic(UUID::class) { + if (uuids == null) { + every { UUID.randomUUID() } returns groupTokenId + } else { + every { UUID.randomUUID() } returnsMany uuids + } + + block() + } + } }