Merge pull request 'feature/#30-group-authentication' (#31) from feature/#30-group-authentication into develop

Reviewed-on: #31
This commit is contained in:
William Nolin 2022-08-03 08:04:10 -04:00
commit 3e6b36ea17
44 changed files with 1558 additions and 806 deletions

View File

@ -106,10 +106,6 @@ tasks.withType<JavaCompile>() {
tasks.withType<KotlinCompile>().all {
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.toString()
freeCompilerArgs = listOf(
"-Xopt-in=kotlin.contracts.ExperimentalContracts",
"-Xinline-classes"
)
}
}

View File

@ -1,17 +1,33 @@
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 {
const val COMPANY = "/api/company"
const val FILE = "/api/file"
const val GROUP = "/api/user/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 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 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 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 {
const val AUTHORIZATION = "Authorization"
const val GROUP_TOKEN = "Group-Token"
}
object FilePaths {
@ -23,8 +39,14 @@ object Constants {
const val RECIPE_IMAGES = "$IMAGES/recipes"
}
object JwtType {
const val USER = 0
const val GROUP = 1
}
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 +69,4 @@ object Constants {
object ValidationRegexes {
const val VALIDATION_COLOR_PATTERN = "^#([0-9a-f]{6})$"
}
}
}

View File

@ -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

View File

@ -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: 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
override fun getCredentials() = id
}

View File

@ -0,0 +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 = 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,
true
)
return UsernamePasswordAuthenticationToken(userDetails, null, userDetails.authorities)
}
override fun supports(authentication: Class<*>) =
authentication.isAssignableFrom(GroupAuthenticationToken::class.java)
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
}
}

View File

@ -1,130 +0,0 @@
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.exception.NotFoundException
import dev.fyloz.colorrecipesexplorer.logic.users.JwtLogic
import dev.fyloz.colorrecipesexplorer.logic.users.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<String>() // 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,
private val userDetailsLogic: UserDetailsLogic
) : BasicAuthenticationFilter(authenticationManager) {
override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, chain: FilterChain) {
fun tryLoginFromBearer(): Boolean {
val authorizationCookie = WebUtils.getCookie(request, authorizationCookieName)
// Check for an authorization token cookie or header
val authorizationToken = if (authorizationCookie != null)
authorizationCookie.value
else
request.getHeader(authorizationCookieName)
// 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
}
fun tryLoginFromDefaultGroupCookie() {
val defaultGroupCookie = WebUtils.getCookie(request, defaultGroupCookieName)
if (defaultGroupCookie != null) {
val authenticationToken = getAuthenticationToken(defaultGroupCookie.value)
SecurityContextHolder.getContext().authentication = authenticationToken
}
}
if (!tryLoginFromBearer())
tryLoginFromDefaultGroupCookie()
chain.doFilter(request, response)
}
private fun getAuthentication(token: String): UsernamePasswordAuthenticationToken? {
return try {
val user = jwtLogic.parseJwt(token.replace("Bearer", ""))
getAuthenticationToken(user)
} catch (_: ExpiredJwtException) {
null
}
}
private fun getAuthenticationToken(user: UserDto) =
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())
}

View File

@ -1,11 +1,16 @@
package dev.fyloz.colorrecipesexplorer.config.security
import dev.fyloz.colorrecipesexplorer.Constants
import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties
import dev.fyloz.colorrecipesexplorer.dtos.UserDto
import dev.fyloz.colorrecipesexplorer.config.security.filters.GroupTokenAuthenticationFilter
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.users.JwtLogic
import dev.fyloz.colorrecipesexplorer.logic.users.UserDetailsLogic
import dev.fyloz.colorrecipesexplorer.logic.users.UserLogic
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
import dev.fyloz.colorrecipesexplorer.model.account.Permission
import mu.KotlinLogging
import org.slf4j.Logger
@ -25,6 +30,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 +45,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,33 +75,43 @@ 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, groupTokenLogic, authManager),
BasicAuthenticationFilter::class.java
)
.addFilterBefore(
UsernamePasswordAuthenticationFilter(
jwtLogic,
securityProperties,
authManager,
this::updateUserLoginTime
)
),
BasicAuthenticationFilter::class.java
)
.addFilter(
JwtAuthorizationFilter(jwtLogic, authenticationManager(), userDetailsLogic)
JwtAuthorizationFilter(jwtLogic, groupTokenLogic, authManager)
)
.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("/api/account/login").permitAll() // Allow access to login
.antMatchers("/api/account/login/group").permitAll() // Allow access to group login
.antMatchers("**").fullyAuthenticated()
if (debugMode) {
if (Constants.DEBUG_MODE) {
http
.cors()
}
@ -103,8 +119,10 @@ 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) {
@ -120,9 +138,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 +187,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 +207,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")

View File

@ -0,0 +1,32 @@
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.GroupTokenLogic
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 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 groupTokenId = groupTokenLogic.getIdForRequest(request)
?: throw BadCredentialsException("Required group token cookie was not present")
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.group!!.id}' using token '${userDetails.id}' (${userDetails.username})")
}
}

View File

@ -0,0 +1,76 @@
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
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())
) {
private val jacksonObjectMapper = jacksonObjectMapper()
override fun successfulAuthentication(
request: HttpServletRequest,
response: HttpServletResponse,
chain: FilterChain,
auth: Authentication
) {
val userDetails = auth.principal as UserDetails
val token = jwtLogic.buildUserJwt(userDetails)
addAuthorizationCookie(response, token)
addResponseBody(userDetails, response)
afterSuccessfulAuthentication(userDetails)
}
protected abstract fun afterSuccessfulAuthentication(userDetails: UserDetails)
private fun addAuthorizationCookie(response: HttpServletResponse, token: String) {
response.addCookie(Constants.CookieNames.AUTHORIZATION, Constants.BEARER_PREFIX + token) {
httpOnly = AUTHORIZATION_COOKIE_HTTP_ONLY
sameSite = AUTHORIZATION_COOKIE_SAME_SITE
secure = !Constants.DEBUG_MODE
maxAge = securityProperties.jwtDuration / 1000
path = AUTHORIZATION_COOKIE_PATH
}
}
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
}
}

View File

@ -0,0 +1,70 @@
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.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
import javax.servlet.FilterChain
import javax.servlet.http.HttpServletRequest
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.CookieNames.AUTHORIZATION)
// If there is no authorization cookie, the user is not authenticated
if (authorizationCookie == null) {
chain.doFilter(request, response)
return
}
val authorizationToken = authorizationCookie.value
if (!isJwtValid(authorizationToken)) {
logger.debug("Received request with invalid ${Constants.CookieNames.AUTHORIZATION} cookie")
chain.doFilter(request, response)
return
}
SecurityContextHolder.getContext().authentication = getAuthentication(authorizationToken)
chain.doFilter(request, response)
}
// The authorization token is valid if it starts with "Bearer"
private fun isJwtValid(authorizationToken: String) =
authorizationToken.startsWith(Constants.BEARER_PREFIX)
private fun getAuthentication(authorizationToken: String): Authentication? {
return try {
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
}
}
private fun getAuthentication(user: UserJwt) =
UsernamePasswordAuthenticationToken(user.id, null, user.authorities)
}

View File

@ -0,0 +1,38 @@
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
val blacklistedJwtTokens = mutableListOf<String>()
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.toLong())
logger.info("User ${userDetails.id} (${userDetails.username}) has logged in successfully")
}
private fun getLoginRequest(request: HttpServletRequest) =
jacksonObjectMapper().readValue(request.inputStream, UserLoginRequestDto::class.java)
}

View File

@ -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<RecipeGroupNoteDto>,
val mixesLocation: List<MixLocationDto>
)
)

View File

@ -1,94 +0,0 @@
package dev.fyloz.colorrecipesexplorer.dtos
import com.fasterxml.jackson.annotation.JsonIgnore
import dev.fyloz.colorrecipesexplorer.Constants
import dev.fyloz.colorrecipesexplorer.SpringUserDetails
import dev.fyloz.colorrecipesexplorer.model.account.Permission
import dev.fyloz.colorrecipesexplorer.model.account.toAuthority
import java.time.LocalDateTime
import javax.validation.constraints.NotBlank
import javax.validation.constraints.Size
data class UserDto(
override val id: Long = 0L,
val firstName: String,
val lastName: String,
@field:JsonIgnore
val password: String = "",
val group: GroupDto?,
val permissions: List<Permission>,
val explicitPermissions: List<Permission> = listOf(),
val lastLoginTime: LocalDateTime? = null,
@field:JsonIgnore
val isDefaultGroupUser: Boolean = false,
@field:JsonIgnore
val isSystemUser: Boolean = false
) : EntityDto {
@get:JsonIgnore
val authorities
get() = permissions
.map { it.toAuthority() }
.toMutableSet()
}
data class UserSaveDto(
val id: Long = 0L,
@field:NotBlank
val firstName: String,
@field:NotBlank
val lastName: String,
@field:NotBlank
@field:Size(min = 8, message = Constants.ValidationMessages.PASSWORD_TOO_SMALL)
val password: String,
val groupId: Long?,
val permissions: List<Permission>,
// TODO WN: Test if working
// @JsonProperty(access = JsonProperty.Access.READ_ONLY)
@field:JsonIgnore
val isSystemUser: Boolean = false,
@field:JsonIgnore
val isDefaultGroupUser: Boolean = false
)
data class UserUpdateDto(
val id: Long = 0L,
@field:NotBlank
val firstName: String,
@field:NotBlank
val lastName: String,
val groupId: Long?,
val permissions: List<Permission>
)
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
override fun isAccountNonExpired() = true
override fun isAccountNonLocked() = true
override fun isCredentialsNonExpired() = true
override fun isEnabled() = true
}

View File

@ -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
@ -15,11 +16,4 @@ data class GroupDto(
val permissions: List<Permission>,
val explicitPermissions: List<Permission> = listOf()
) : EntityDto {
@get:JsonIgnore
val defaultGroupUserId = getDefaultGroupUserId(id)
companion object {
fun getDefaultGroupUserId(id: Long) = 1000000 + id
}
}
) : EntityDto

View File

@ -0,0 +1,23 @@
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 enabled: Boolean,
val isDeleted: Boolean,
val group: GroupDto
)
data class GroupTokenSaveDto(
@field:NotBlank
val name: String,
val groupId: Long
)

View File

@ -0,0 +1,98 @@
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 org.springframework.security.core.GrantedAuthority
import java.time.LocalDateTime
import javax.validation.constraints.NotBlank
import javax.validation.constraints.Size
data class UserDto(
override val id: Long = 0L,
val firstName: String,
val lastName: String,
@field:JsonIgnore val password: String = "",
val group: GroupDto?,
val permissions: List<Permission>,
val explicitPermissions: List<Permission> = listOf(),
val lastLoginTime: LocalDateTime? = null,
@field:JsonIgnore val isSystemUser: Boolean = false
) : EntityDto {
@get:JsonIgnore
val fullName = "$firstName $lastName"
}
data class UserSaveDto(
val id: Long = 0L,
@field:NotBlank val firstName: String,
@field:NotBlank val lastName: String,
@field:NotBlank @field:Size(
min = 8, message = Constants.ValidationMessages.PASSWORD_TOO_SMALL
) val password: String,
val groupId: Long?,
val permissions: List<Permission>,
@field:JsonIgnore val isSystemUser: Boolean = false
)
data class UserUpdateDto(
val id: Long = 0L,
@field:NotBlank val firstName: String,
@field:NotBlank val lastName: String,
val groupId: Long?,
val permissions: List<Permission>
)
data class UserLoginRequestDto(val id: Long, val password: String)
data class UserJwt(val id: String, val authorities: Collection<GrantedAuthority>, val isGroup: Boolean)
class UserDetails(
val id: String,
private val username: String,
private val password: String,
val group: GroupDto?,
val permissions: Collection<Permission>,
val isGroup: Boolean = false
) : SpringUserDetails {
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 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<Permission>
)

View File

@ -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

View File

@ -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<InvalidPositionError>
get() = exception.errors
}
}

View File

@ -0,0 +1,41 @@
package dev.fyloz.colorrecipesexplorer.logic.account
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.logic.BaseLogic
import dev.fyloz.colorrecipesexplorer.logic.Logic
import dev.fyloz.colorrecipesexplorer.service.account.GroupService
import org.springframework.transaction.annotation.Transactional
interface GroupLogic : Logic<GroupDto, GroupService> {
/** Gets all the users of the group with the given [id]. */
fun getUsersForGroup(id: Long): Collection<UserDto>
}
@LogicComponent
class DefaultGroupLogic(service: GroupService, private val userLogic: UserLogic) :
BaseLogic<GroupDto, GroupService>(service, Constants.ModelNames.GROUP),
GroupLogic {
override fun getUsersForGroup(id: Long) = userLogic.getAllByGroup(getById(id))
@Transactional
override fun save(dto: GroupDto): GroupDto {
throwIfNameAlreadyExists(dto.name)
return super.save(dto)
}
override fun update(dto: GroupDto): GroupDto {
throwIfNameAlreadyExists(dto.name, dto.id)
return super.update(dto)
}
private fun throwIfNameAlreadyExists(name: String, id: Long? = null) {
if (service.existsByName(name, id)) {
throw alreadyExistsException(value = name)
}
}
}

View File

@ -0,0 +1,131 @@
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 dev.fyloz.colorrecipesexplorer.utils.parseBearer
import org.springframework.web.util.WebUtils
import java.util.*
import javax.annotation.PostConstruct
import javax.servlet.http.HttpServletRequest
interface GroupTokenLogic {
fun isDisabled(id: String): Boolean
fun getAll(): Collection<GroupTokenDto>
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
fun deleteById(id: String)
}
@LogicComponent
class DefaultGroupTokenLogic(
private val service: GroupTokenService,
private val groupLogic: GroupLogic,
private val jwtLogic: JwtLogic,
private val enabledTokensCache: HashSet<String> = hashSetOf() // In constructor for unit testing
) : GroupTokenLogic {
private val typeName = Constants.ModelNames.GROUP_TOKEN
private val typeNameLowerCase = typeName.lowercase()
@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 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)
val id = generateRandomUUID()
val token = GroupTokenDto(
id, dto.name, enabled = true, isDeleted = false, group = groupLogic.getById(dto.groupId)
)
val savedToken = service.save(token)
enabledTokensCache.add(savedToken.id.toString())
return savedToken
}
override fun enable(id: String) = setEnabled(id, true).also {
if (isDisabled(id)) {
enabledTokensCache.add(id)
}
}
override fun disable(id: String) = setEnabled(id, false).also {
if (!isDisabled(id)) {
enabledTokensCache.remove(id)
}
}
override fun deleteById(id: String) {
val token = getById(id).copy(enabled = false, isDeleted = true)
service.save(token)
enabledTokensCache.remove(id)
}
private fun setEnabled(id: String, enabled: Boolean) = with(getById(id)) {
service.save(this.copy(enabled = enabled))
}
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)) {
throw alreadyExistsException(value = name)
}
}
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,
"$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
)
}

View File

@ -0,0 +1,109 @@
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.account.UserDetails
import dev.fyloz.colorrecipesexplorer.dtos.account.UserJwt
import dev.fyloz.colorrecipesexplorer.model.account.Permission
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.stereotype.Service
import java.time.Instant
import java.util.*
interface JwtLogic {
/** Build a JWT for the given [userDetails]. */
fun buildUserJwt(userDetails: UserDetails): String
/** 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
class DefaultJwtLogic(
val objectMapper: ObjectMapper,
val securityProperties: CreSecurityProperties
) : JwtLogic {
private val secretKey by lazy {
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<Map<String, *>>(objectMapper))
.signWith(secretKey)
private val jwtParser by lazy {
Jwts.parserBuilder()
.deserializeJsonWith(JacksonDeserializer<Map<String, *>>(objectMapper))
.setSigningKey(secretKey)
.build()
}
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(permissionsIds))
.claim(JWT_CLAIM_TYPE, type)
.compact()
}
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)
val permissionsIds = objectMapper.readValue<Collection<Int>>(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, isGroup)
}
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)
.toDate()
companion object {
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
}
}

View File

@ -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
@ -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,18 +25,14 @@ 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 {
val user = userLogic.getById(
id,
isSystemUser = true,
isDefaultGroupUser = isDefaultGroupUser
)
override fun loadUserById(id: Long): UserDetails {
val user = userLogic.getById(id, isSystemUser = true)
return UserDetails(user)
}
}
@ -69,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())

View File

@ -1,20 +1,18 @@
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.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
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
@ -25,13 +23,7 @@ interface UserLogic : Logic<UserDto, UserService> {
fun getAllByGroup(group: GroupDto): Collection<UserDto>
/** 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
@ -59,30 +51,13 @@ interface UserLogic : Logic<UserDto, UserService> {
class DefaultUserLogic(
service: UserService, @Lazy private val groupLogic: GroupLogic, @Lazy private val passwordEncoder: PasswordEncoder
) : BaseLogic<UserDto, UserService>(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(
@ -92,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
)
)
@ -105,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)
return update(
user.copy(
@ -123,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))
}
@ -140,7 +114,7 @@ class DefaultUserLogic(
}
override fun logout(request: HttpServletRequest) {
val authorizationCookie = WebUtils.getCookie(request, authorizationCookieName)
val authorizationCookie = WebUtils.getCookie(request, Constants.CookieNames.AUTHORIZATION)
if (authorizationCookie != null) {
val authorizationToken = authorizationCookie.value
if (authorizationToken != null && authorizationToken.startsWith("Bearer")) {
@ -166,4 +140,4 @@ class DefaultUserLogic(
)
}
}
}
}

View File

@ -1,80 +0,0 @@
package dev.fyloz.colorrecipesexplorer.logic.users
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.exception.NoDefaultGroupException
import dev.fyloz.colorrecipesexplorer.logic.BaseLogic
import dev.fyloz.colorrecipesexplorer.logic.Logic
import dev.fyloz.colorrecipesexplorer.service.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<GroupDto, GroupService> {
/** Gets all the users of the group with the given [id]. */
fun getUsersForGroup(id: Long): Collection<UserDto>
/** 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
class DefaultGroupLogic(service: GroupService, private val userLogic: UserLogic) :
BaseLogic<GroupDto, GroupService>(service, Constants.ModelNames.GROUP),
GroupLogic {
override fun getUsersForGroup(id: Long) = userLogic.getAllByGroup(getById(id))
override fun getRequestDefaultGroup(request: HttpServletRequest): GroupDto {
val defaultGroupCookie = WebUtils.getCookie(request, defaultGroupCookieName)
?: 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",
"$defaultGroupCookieName=${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)
}
}
override fun update(dto: GroupDto): GroupDto {
throwIfNameAlreadyExists(dto.name, dto.id)
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)
}
}
}

View File

@ -1,77 +0,0 @@
package dev.fyloz.colorrecipesexplorer.logic.users
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.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.stereotype.Service
import java.time.Instant
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 token for the given [user]. */
fun buildJwt(user: UserDto): String
/** Parses a user from the given [jwt] token. */
fun parseJwt(jwt: String): UserDto
}
@Service
class DefaultJwtLogic(
val objectMapper: ObjectMapper,
val securityProperties: CreSecurityProperties
) : JwtLogic {
private val secretKey by lazy {
securityProperties.jwtSecret.base64encode()
}
private val jwtBuilder by lazy {
Jwts.builder()
.serializeToJsonWith(JacksonSerializer<Map<String, *>>(objectMapper))
.signWith(secretKey)
}
private val jwtParser by lazy {
Jwts.parserBuilder()
.deserializeJsonWith(JacksonDeserializer<Map<String, *>>(objectMapper))
.setSigningKey(secretKey)
.build()
}
override fun buildJwt(userDetails: UserDetails) =
buildJwt(userDetails.user)
override fun buildJwt(user: UserDto): String =
jwtBuilder
.setSubject(user.id.toString())
.setExpiration(getCurrentExpirationDate())
.claim(jwtClaimUser, user.serialize())
.compact()
override fun parseJwt(jwt: String): UserDto =
with(
jwtParser.parseClaimsJws(jwt)
.body.get(jwtClaimUser, String::class.java)
) {
objectMapper.readValue(this)
}
private fun getCurrentExpirationDate(): Date =
Instant.now()
.plusSeconds(securityProperties.jwtDuration)
.toDate()
private fun UserDto.serialize(): String =
objectMapper.writeValueAsString(this)
}

View File

@ -0,0 +1,29 @@
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,
@Column(name = "deleted")
val isDeleted: Boolean,
@ManyToOne
@JoinColumn(name = "group_id")
val group: Group
)

View File

@ -4,32 +4,34 @@ import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
enum class Permission(
val impliedPermissions: List<Permission> = listOf(),
val id: Int,
private val impliedPermissions: List<Permission> = 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<Permission> {
return mutableSetOf(this).apply {
impliedPermissions.forEach {
addAll(it.flat())
fun flat(): Iterable<Permission> {
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)
}
}

View File

@ -1,26 +1,34 @@
package dev.fyloz.colorrecipesexplorer.repository
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.*
/**
* 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<User, Long> {
fun findAllByIsDefaultGroupUserIsFalse(): MutableList<User>
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<User>
/** 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
@ -28,3 +36,15 @@ interface GroupRepository : JpaRepository<Group, Long> {
/** Checks if a group with the given [name] and a different [id] exists. */
fun existsByNameAndIdNot(name: String, id: Long): Boolean
}
@Repository
interface GroupTokenRepository : JpaRepository<GroupToken, UUID> {
/** Checks if a token that is not deleted with the given [name] exists. */
fun existsByNameAndIsDeletedIsFalse(name: String): Boolean
/** Finds all group tokens that are not deleted. */
fun findAllByIsDeletedIsFalse(): Collection<GroupToken>
/** Finds the group token with the given [id] if it is not deleted. */
fun findByIdAndIsDeletedIsFalse(id: UUID): GroupToken?
}

View File

@ -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

View File

@ -0,0 +1,62 @@
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.JwtLogic
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
) {
@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
@PreAuthorizeEditUsers
fun save(@Valid @RequestBody group: GroupDto) =
created<GroupDto>(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)
}
}

View File

@ -0,0 +1,79 @@
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.logic.account.JwtLogic
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.http.ResponseEntity
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_TOKEN)
@PreAuthorizeAdmin
@Profile("!emergency")
class GroupTokenController(
private val groupTokenLogic: GroupTokenLogic, private val jwtLogic: JwtLogic
) {
@GetMapping
fun getAll() = ok(groupTokenLogic.getAll())
@GetMapping("current")
fun getCurrent(request: HttpServletRequest): ResponseEntity<GroupTokenDto?> {
val id = groupTokenLogic.getIdForRequest(request) ?: return ok(null)
return ok(groupTokenLogic.getById(id))
}
@GetMapping("{id}")
fun getById(@PathVariable id: String) = ok(groupTokenLogic.getById(id))
@PostMapping
fun save(@RequestBody @Valid dto: GroupTokenSaveDto, response: HttpServletResponse) =
with(groupTokenLogic.save(dto)) {
addGroupTokenCookie(response, this)
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)
}
private fun addGroupTokenCookie(response: HttpServletResponse, groupToken: GroupTokenDto) {
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 = null // This cookie should never expire
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_PATH = Constants.ControllerPaths.ACCOUNT_BASE_PATH
}
}

View File

@ -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
@ -78,74 +78,10 @@ class UserController(private val userLogic: UserLogic) {
}
@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<GroupDto>(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")
@RequestMapping(Constants.ControllerPaths.LOGOUT)
@Profile("!emergency")
class LogoutController(private val userLogic: UserLogic) {
@GetMapping("logout")
@GetMapping
@PreAuthorize("isFullyAuthenticated()")
fun logout(request: HttpServletRequest) =
ok {

View File

@ -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())
}
}
}

View File

@ -1,11 +1,12 @@
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<GroupDto, Group, GroupRepository> {
/** Checks if a group with the given [name] and a different [id] exists. */
@ -28,4 +29,4 @@ class DefaultGroupService(repository: GroupRepository) : BaseService<GroupDto, G
override fun flattenPermissions(group: Group) =
group.permissions.flatMap { it.flat() }.filter { !it.deprecated }
}
}

View File

@ -0,0 +1,47 @@
package dev.fyloz.colorrecipesexplorer.service.account
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 java.util.UUID
interface GroupTokenService {
fun existsById(id: UUID): Boolean
fun existsByName(name: String): Boolean
fun getAll(): Collection<GroupTokenDto>
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.existsByNameAndIsDeletedIsFalse(name)
override fun getAll() = repository.findAllByIsDeletedIsFalse().map(::toDto)
override fun getById(id: UUID): GroupTokenDto? {
val entity = repository.findByIdAndIsDeletedIsFalse(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, entity.isDeleted, groupService.toDto(entity.group))
override fun toEntity(dto: GroupTokenDto) =
GroupToken(dto.id, dto.name, dto.enabled, dto.isDeleted, groupService.toEntity(dto.group))
}

View File

@ -1,55 +1,49 @@
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 org.springframework.data.repository.findByIdOrNull
import dev.fyloz.colorrecipesexplorer.service.BaseService
import dev.fyloz.colorrecipesexplorer.service.Service
interface UserService : Service<UserDto, User, UserRepository> {
/** 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<UserDto>
/** Gets all users, depending on [isSystemUser]. */
fun getAll(isSystemUser: Boolean): Collection<UserDto>
/** Gets all users for the given [group]. */
fun getAllByGroup(group: GroupDto): Collection<UserDto>
/** 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<UserDto, User, UserRepository>(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
}
@ -61,11 +55,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,
@ -75,7 +64,6 @@ class DefaultUserService(repository: UserRepository, private val groupService: G
getFlattenPermissions(entity),
entity.permissions,
entity.lastLoginTime,
entity.isDefaultGroupUser,
entity.isSystemUser
)
@ -84,7 +72,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,
@ -98,6 +86,6 @@ class DefaultUserService(repository: UserRepository, private val groupService: G
return perms + groupService.flattenPermissions(user.group)
}
return perms
return perms.distinctBy { it.id }
}
}
}

View File

@ -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) {
@ -42,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;")
}
@ -50,6 +60,10 @@ 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()
}
fun parseBearer(source: String) =
source.replace(Constants.BEARER_PREFIX, "").trim()

View File

@ -1 +1 @@
spring.jpa.show-sql=true
spring.jpa.show-sql=false

View File

@ -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.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.utils.base64encode
import dev.fyloz.colorrecipesexplorer.utils.isAround
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.jackson.io.JacksonDeserializer
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.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<Map<String, *>>(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.buildJwt(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.buildJwt(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.buildJwt(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.buildJwt(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.buildJwt(user)
val parsedUser = jwtService.parseJwt(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)
}
}

View File

@ -1,8 +1,9 @@
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.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 +214,4 @@ class DefaultRecipeLogicTest {
mixLogicMock.updateLocations(any())
}
}
}
}

View File

@ -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<InvalidGroupStepsPositionsException> { recipeStepLogic.validateGroupInformationSteps(groupInfo) }
}
}
}

View File

@ -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
@ -25,9 +23,7 @@ class DefaultGroupLogicTest {
}
private val userLogicMock = mockk<UserLogic> {
every { getAllByGroup(any()) } returns listOf()
every { getById(any(), any(), any()) } returns user
every { getDefaultGroupUser(any()) } returns user
every { saveDefaultGroupUser(any()) } just runs
every { getById(any(), any()) } returns user
every { deleteById(any()) } just runs
}
@ -72,16 +68,4 @@ class DefaultGroupLogicTest {
// Assert
assertThrows<AlreadyExistsException> { groupLogic.update(group) }
}
@Test
fun deleteById_normalBehavior_callsDeleteByIdInUserLogicWithDefaultGroupUserId() {
// Arrange
// Act
groupLogic.deleteById(group.id)
// Assert
verify {
userLogicMock.deleteById(group.defaultGroupUserId)
}
}
}
}

View File

@ -0,0 +1,298 @@
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
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 org.springframework.web.util.WebUtils
import java.util.*
import javax.servlet.http.Cookie
import javax.servlet.http.HttpServletRequest
import kotlin.test.*
class DefaultGroupTokenLogicTest {
private val groupTokenServiceMock = mockk<GroupTokenService>()
private val groupLogicMock = mockk<GroupLogic>()
private val jwtLogicMock = mockk<JwtLogic>()
private val enabledTokenCache = hashSetOf<String>()
private val groupTokenLogic =
spyk(DefaultGroupTokenLogic(groupTokenServiceMock, groupLogicMock, 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, false, 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<UUID>()) } 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<NotFoundException> { groupTokenLogic.getById(groupTokenId) }
}
@Test
fun getIdForRequest_normalBehavior_returnsGroupTokenIdFromRequest() {
// Arrange
val request = mockk<HttpServletRequest>()
val cookie = mockk<Cookie> {
every { value } returns "Bearer$groupTokenIdStr"
}
mockkStatic(WebUtils::class) {
every { WebUtils.getCookie(any(), Constants.CookieNames.GROUP_TOKEN) } returns cookie
}
}
@Test
fun save_normalBehavior_callsSaveInService() {
// Arrange
every { groupTokenServiceMock.existsByName(any()) } returns false
every { groupTokenServiceMock.existsById(any()) } returns false
every { groupTokenServiceMock.save(any()) } returns groupToken
every { groupLogicMock.getById(any()) } returns group
// Act
withMockRandomUUID {
groupTokenLogic.save(groupTokenSaveDto)
}
// Assert
verify {
groupTokenServiceMock.save(groupToken)
}
}
@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
withMockRandomUUID {
groupTokenLogic.save(groupTokenSaveDto)
}
// Assert
assertContains(enabledTokenCache, groupTokenIdStr)
}
@Test
fun save_nameAlreadyExists_throwsAlreadyExistsException() {
// Arrange
every { groupTokenServiceMock.existsByName(any()) } returns true
// Act
// Assert
assertThrows<AlreadyExistsException> { groupTokenLogic.save(groupTokenSaveDto) }
}
@Test
fun enable_normalBehavior_savesTokenInService() {
// Arrange
every { groupTokenServiceMock.save(any()) } returns groupToken
every { groupTokenLogic.getById(any<String>()) } 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<String>()) } 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<String>()) } 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<String>()) } returns groupToken
// Act
groupTokenLogic.disable(groupTokenIdStr)
// Assert
assertFalse(enabledTokenCache.contains(groupTokenIdStr))
}
@Test
fun deleteById_normalBehavior_savesDeletedTokenInService() {
// Arrange
every { groupTokenLogic.getById(any<String>()) } answers { groupToken }
every { groupTokenServiceMock.save(any()) } answers { firstArg() }
// Act
groupTokenLogic.deleteById(groupTokenIdStr)
// Assert
verify {
groupTokenServiceMock.save(match {
it.id == groupTokenId && it.isDeleted
})
}
}
@Test
fun deleteById_normalBehavior_removesIdFromEnabledTokensCache() {
// Arrange
every { groupTokenLogic.getById(any<String>()) } answers { groupToken }
every { groupTokenServiceMock.save(any()) } answers { firstArg() }
// Act
groupTokenLogic.deleteById(groupTokenIdStr)
// Assert
assertFalse(enabledTokenCache.contains(groupTokenIdStr))
}
private fun withMockRandomUUID(uuids: List<UUID>? = null, block: () -> Unit) {
mockkStatic(UUID::class) {
if (uuids == null) {
every { UUID.randomUUID() } returns groupTokenId
} else {
every { UUID.randomUUID() } returnsMany uuids
}
block()
}
}
}

View File

@ -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
@ -24,11 +22,10 @@ class DefaultUserLogicTest {
private val userServiceMock = mockk<UserService> {
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<GroupLogic> {
every { getById(any()) } returns group
@ -46,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())
@ -64,7 +60,7 @@ class DefaultUserLogicTest {
// Assert
verify {
userServiceMock.getAll(isSystemUser = false, isDefaultGroupUser = false)
userServiceMock.getAll(isSystemUser = false)
}
confirmVerified(userServiceMock)
}
@ -90,7 +86,7 @@ class DefaultUserLogicTest {
// Assert
verify {
userLogic.getById(user.id, isSystemUser = false, isDefaultGroupUser = false)
userLogic.getById(user.id, isSystemUser = false)
}
}
@ -98,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)
}
@ -110,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<NotFoundException> { 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<NotFoundException> { userLogic.getDefaultGroupUser(group) }
}
@Test
fun saveDefaultGroupUser_normalBehavior_callsSaveWithValidSaveDto() {
// Arrange
every { userLogic.save(any<UserSaveDto>()) } 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
@ -210,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<UserDto>()) } returns user
// Act
@ -303,4 +258,4 @@ class DefaultUserLogicTest {
userLogic.update(user)
}
}
}
}