Merge pull request 'feature/#30-group-authentication' (#31) from feature/#30-group-authentication into develop
Reviewed-on: #31
This commit is contained in:
commit
3e6b36ea17
|
@ -106,10 +106,6 @@ tasks.withType<JavaCompile>() {
|
||||||
tasks.withType<KotlinCompile>().all {
|
tasks.withType<KotlinCompile>().all {
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||||
freeCompilerArgs = listOf(
|
|
||||||
"-Xopt-in=kotlin.contracts.ExperimentalContracts",
|
|
||||||
"-Xinline-classes"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,17 +1,33 @@
|
||||||
package dev.fyloz.colorrecipesexplorer
|
package dev.fyloz.colorrecipesexplorer
|
||||||
|
|
||||||
object Constants {
|
object Constants {
|
||||||
|
val BEARER_PREFIX = "Bearer"
|
||||||
|
var DEBUG_MODE = false // Not really a constant, but should never change after the app startup
|
||||||
|
|
||||||
object ControllerPaths {
|
object ControllerPaths {
|
||||||
const val COMPANY = "/api/company"
|
const val BASE_PATH = "/api"
|
||||||
const val FILE = "/api/file"
|
const val ACCOUNT_BASE_PATH = "$BASE_PATH/account"
|
||||||
const val GROUP = "/api/user/group"
|
|
||||||
const val INVENTORY = "/api/inventory"
|
const val COMPANY = "$BASE_PATH/company"
|
||||||
const val MATERIAL = "/api/material"
|
const val GROUP_TOKEN = "$BASE_PATH/account/group/token"
|
||||||
const val MATERIAL_TYPE = "/api/materialtype"
|
const val FILE = "$BASE_PATH/file"
|
||||||
const val MIX = "/api/recipe/mix"
|
const val INVENTORY = "$BASE_PATH/inventory"
|
||||||
const val RECIPE = "/api/recipe"
|
const val MATERIAL = "$BASE_PATH/material"
|
||||||
const val TOUCH_UP_KIT = "/api/touchupkit"
|
const val MATERIAL_TYPE = "$BASE_PATH/materialtype"
|
||||||
const val USER = "/api/user"
|
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 {
|
object FilePaths {
|
||||||
|
@ -23,8 +39,14 @@ object Constants {
|
||||||
const val RECIPE_IMAGES = "$IMAGES/recipes"
|
const val RECIPE_IMAGES = "$IMAGES/recipes"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
object JwtType {
|
||||||
|
const val USER = 0
|
||||||
|
const val GROUP = 1
|
||||||
|
}
|
||||||
|
|
||||||
object ModelNames {
|
object ModelNames {
|
||||||
const val COMPANY = "Company"
|
const val COMPANY = "Company"
|
||||||
|
const val GROUP_TOKEN = "GroupToken"
|
||||||
const val GROUP = "Group"
|
const val GROUP = "Group"
|
||||||
const val MATERIAL = "Material"
|
const val MATERIAL = "Material"
|
||||||
const val MATERIAL_TYPE = "MaterialType"
|
const val MATERIAL_TYPE = "MaterialType"
|
||||||
|
@ -47,4 +69,4 @@ object Constants {
|
||||||
object ValidationRegexes {
|
object ValidationRegexes {
|
||||||
const val VALIDATION_COLOR_PATTERN = "^#([0-9a-f]{6})$"
|
const val VALIDATION_COLOR_PATTERN = "^#([0-9a-f]{6})$"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,12 @@ package dev.fyloz.colorrecipesexplorer.config.annotations
|
||||||
|
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
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)
|
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS)
|
||||||
@Retention(AnnotationRetention.RUNTIME)
|
@Retention(AnnotationRetention.RUNTIME)
|
||||||
@MustBeDocumented
|
@MustBeDocumented
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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())
|
|
||||||
}
|
|
|
@ -1,11 +1,16 @@
|
||||||
package dev.fyloz.colorrecipesexplorer.config.security
|
package dev.fyloz.colorrecipesexplorer.config.security
|
||||||
|
|
||||||
|
import dev.fyloz.colorrecipesexplorer.Constants
|
||||||
import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties
|
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.emergencyMode
|
||||||
import dev.fyloz.colorrecipesexplorer.logic.users.JwtLogic
|
import dev.fyloz.colorrecipesexplorer.logic.account.GroupTokenLogic
|
||||||
import dev.fyloz.colorrecipesexplorer.logic.users.UserDetailsLogic
|
import dev.fyloz.colorrecipesexplorer.logic.account.JwtLogic
|
||||||
import dev.fyloz.colorrecipesexplorer.logic.users.UserLogic
|
import dev.fyloz.colorrecipesexplorer.logic.account.UserDetailsLogic
|
||||||
|
import dev.fyloz.colorrecipesexplorer.logic.account.UserLogic
|
||||||
import dev.fyloz.colorrecipesexplorer.model.account.Permission
|
import dev.fyloz.colorrecipesexplorer.model.account.Permission
|
||||||
import mu.KotlinLogging
|
import mu.KotlinLogging
|
||||||
import org.slf4j.Logger
|
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.core.AuthenticationException
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
|
||||||
import org.springframework.security.web.AuthenticationEntryPoint
|
import org.springframework.security.web.AuthenticationEntryPoint
|
||||||
|
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter
|
||||||
import org.springframework.stereotype.Component
|
import org.springframework.stereotype.Component
|
||||||
import org.springframework.web.cors.CorsConfiguration
|
import org.springframework.web.cors.CorsConfiguration
|
||||||
import org.springframework.web.cors.UrlBasedCorsConfigurationSource
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource
|
||||||
|
@ -39,13 +45,13 @@ private const val rootUserLastName = "User"
|
||||||
abstract class BaseSecurityConfig(
|
abstract class BaseSecurityConfig(
|
||||||
private val userDetailsLogic: UserDetailsLogic,
|
private val userDetailsLogic: UserDetailsLogic,
|
||||||
private val jwtLogic: JwtLogic,
|
private val jwtLogic: JwtLogic,
|
||||||
|
private val groupTokenLogic: GroupTokenLogic,
|
||||||
private val environment: Environment,
|
private val environment: Environment,
|
||||||
protected val securityProperties: CreSecurityProperties
|
protected val securityProperties: CreSecurityProperties
|
||||||
) : WebSecurityConfigurerAdapter() {
|
) : WebSecurityConfigurerAdapter() {
|
||||||
protected abstract val logger: Logger
|
protected abstract val logger: Logger
|
||||||
|
|
||||||
protected val passwordEncoder = BCryptPasswordEncoder()
|
protected val passwordEncoder = BCryptPasswordEncoder()
|
||||||
var debugMode = false
|
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
open fun passwordEncoder() =
|
open fun passwordEncoder() =
|
||||||
|
@ -69,33 +75,43 @@ abstract class BaseSecurityConfig(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun configure(authBuilder: AuthenticationManagerBuilder) {
|
override fun configure(authBuilder: AuthenticationManagerBuilder) {
|
||||||
authBuilder.userDetailsService(userDetailsLogic).passwordEncoder(passwordEncoder)
|
authBuilder
|
||||||
|
.authenticationProvider(GroupTokenAuthenticationProvider(groupTokenLogic))
|
||||||
|
.userDetailsService(userDetailsLogic).passwordEncoder(passwordEncoder)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun configure(http: HttpSecurity) {
|
override fun configure(http: HttpSecurity) {
|
||||||
|
val authManager = authenticationManager()
|
||||||
|
|
||||||
http
|
http
|
||||||
.headers().frameOptions().disable()
|
.headers().frameOptions().disable()
|
||||||
.and()
|
.and()
|
||||||
.csrf().disable()
|
.csrf().disable()
|
||||||
.addFilter(
|
.addFilterBefore(
|
||||||
JwtAuthenticationFilter(
|
GroupTokenAuthenticationFilter(jwtLogic, securityProperties, groupTokenLogic, authManager),
|
||||||
authenticationManager(),
|
BasicAuthenticationFilter::class.java
|
||||||
|
)
|
||||||
|
.addFilterBefore(
|
||||||
|
UsernamePasswordAuthenticationFilter(
|
||||||
jwtLogic,
|
jwtLogic,
|
||||||
securityProperties,
|
securityProperties,
|
||||||
|
authManager,
|
||||||
this::updateUserLoginTime
|
this::updateUserLoginTime
|
||||||
)
|
),
|
||||||
|
BasicAuthenticationFilter::class.java
|
||||||
)
|
)
|
||||||
.addFilter(
|
.addFilter(
|
||||||
JwtAuthorizationFilter(jwtLogic, authenticationManager(), userDetailsLogic)
|
JwtAuthorizationFilter(jwtLogic, groupTokenLogic, authManager)
|
||||||
)
|
)
|
||||||
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
||||||
.and()
|
.and()
|
||||||
.authorizeRequests()
|
.authorizeRequests()
|
||||||
.antMatchers("/api/config/**").permitAll() // Allow access to logo and icon
|
.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()
|
.antMatchers("**").fullyAuthenticated()
|
||||||
|
|
||||||
if (debugMode) {
|
if (Constants.DEBUG_MODE) {
|
||||||
http
|
http
|
||||||
.cors()
|
.cors()
|
||||||
}
|
}
|
||||||
|
@ -103,8 +119,10 @@ abstract class BaseSecurityConfig(
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
fun initDebugMode() {
|
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!")
|
if (debugMode) logger.warn("Debug mode is enabled, security will be decreased!")
|
||||||
|
|
||||||
|
Constants.DEBUG_MODE = debugMode
|
||||||
}
|
}
|
||||||
|
|
||||||
protected open fun updateUserLoginTime(userId: Long) {
|
protected open fun updateUserLoginTime(userId: Long) {
|
||||||
|
@ -120,9 +138,10 @@ class SecurityConfig(
|
||||||
@Lazy userDetailsLogic: UserDetailsLogic,
|
@Lazy userDetailsLogic: UserDetailsLogic,
|
||||||
@Lazy private val userLogic: UserLogic,
|
@Lazy private val userLogic: UserLogic,
|
||||||
jwtLogic: JwtLogic,
|
jwtLogic: JwtLogic,
|
||||||
|
groupTokenLogic: GroupTokenLogic,
|
||||||
environment: Environment,
|
environment: Environment,
|
||||||
securityProperties: CreSecurityProperties
|
securityProperties: CreSecurityProperties
|
||||||
) : BaseSecurityConfig(userDetailsLogic, jwtLogic, environment, securityProperties) {
|
) : BaseSecurityConfig(userDetailsLogic, jwtLogic, groupTokenLogic, environment, securityProperties) {
|
||||||
override val logger = KotlinLogging.logger {}
|
override val logger = KotlinLogging.logger {}
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
|
@ -168,9 +187,10 @@ class SecurityConfig(
|
||||||
class EmergencySecurityConfig(
|
class EmergencySecurityConfig(
|
||||||
userDetailsLogic: UserDetailsLogic,
|
userDetailsLogic: UserDetailsLogic,
|
||||||
jwtLogic: JwtLogic,
|
jwtLogic: JwtLogic,
|
||||||
|
groupTokenLogic: GroupTokenLogic,
|
||||||
environment: Environment,
|
environment: Environment,
|
||||||
securityProperties: CreSecurityProperties
|
securityProperties: CreSecurityProperties
|
||||||
) : BaseSecurityConfig(userDetailsLogic, jwtLogic, environment, securityProperties) {
|
) : BaseSecurityConfig(userDetailsLogic, jwtLogic, groupTokenLogic, environment, securityProperties) {
|
||||||
override val logger = KotlinLogging.logger {}
|
override val logger = KotlinLogging.logger {}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
@ -187,5 +207,5 @@ class RestAuthenticationEntryPoint : AuthenticationEntryPoint {
|
||||||
) = response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized")
|
) = response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized")
|
||||||
}
|
}
|
||||||
|
|
||||||
private class InvalidSystemUserException(userType: String, message: String) :
|
class InvalidSystemUserException(userType: String, message: String) :
|
||||||
RuntimeException("Invalid $userType user: $message")
|
RuntimeException("Invalid $userType user: $message")
|
||||||
|
|
|
@ -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})")
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -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)
|
||||||
|
}
|
|
@ -2,6 +2,7 @@ package dev.fyloz.colorrecipesexplorer.dtos
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
import dev.fyloz.colorrecipesexplorer.Constants
|
import dev.fyloz.colorrecipesexplorer.Constants
|
||||||
|
import dev.fyloz.colorrecipesexplorer.dtos.account.GroupDto
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
import javax.validation.constraints.Max
|
import javax.validation.constraints.Max
|
||||||
import javax.validation.constraints.Min
|
import javax.validation.constraints.Min
|
||||||
|
@ -118,4 +119,4 @@ data class RecipePublicDataDto(
|
||||||
val notes: List<RecipeGroupNoteDto>,
|
val notes: List<RecipeGroupNoteDto>,
|
||||||
|
|
||||||
val mixesLocation: List<MixLocationDto>
|
val mixesLocation: List<MixLocationDto>
|
||||||
)
|
)
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -1,6 +1,7 @@
|
||||||
package dev.fyloz.colorrecipesexplorer.dtos
|
package dev.fyloz.colorrecipesexplorer.dtos.account
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
|
import dev.fyloz.colorrecipesexplorer.dtos.EntityDto
|
||||||
import dev.fyloz.colorrecipesexplorer.model.account.Permission
|
import dev.fyloz.colorrecipesexplorer.model.account.Permission
|
||||||
import javax.validation.constraints.NotBlank
|
import javax.validation.constraints.NotBlank
|
||||||
import javax.validation.constraints.NotEmpty
|
import javax.validation.constraints.NotEmpty
|
||||||
|
@ -15,11 +16,4 @@ data class GroupDto(
|
||||||
val permissions: List<Permission>,
|
val permissions: List<Permission>,
|
||||||
|
|
||||||
val explicitPermissions: List<Permission> = listOf()
|
val explicitPermissions: List<Permission> = listOf()
|
||||||
) : EntityDto {
|
) : EntityDto
|
||||||
@get:JsonIgnore
|
|
||||||
val defaultGroupUserId = getDefaultGroupUserId(id)
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
fun getDefaultGroupUserId(id: Long) = 1000000 + id
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
||||||
|
)
|
|
@ -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>
|
||||||
|
)
|
|
@ -5,7 +5,7 @@ import dev.fyloz.colorrecipesexplorer.config.annotations.LogicComponent
|
||||||
import dev.fyloz.colorrecipesexplorer.dtos.*
|
import dev.fyloz.colorrecipesexplorer.dtos.*
|
||||||
import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException
|
import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException
|
||||||
import dev.fyloz.colorrecipesexplorer.logic.files.WriteableFileLogic
|
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.service.RecipeService
|
||||||
import dev.fyloz.colorrecipesexplorer.utils.collections.LazyMapList
|
import dev.fyloz.colorrecipesexplorer.utils.collections.LazyMapList
|
||||||
import dev.fyloz.colorrecipesexplorer.utils.merge
|
import dev.fyloz.colorrecipesexplorer.utils.merge
|
||||||
|
|
|
@ -2,13 +2,12 @@ package dev.fyloz.colorrecipesexplorer.logic
|
||||||
|
|
||||||
import dev.fyloz.colorrecipesexplorer.Constants
|
import dev.fyloz.colorrecipesexplorer.Constants
|
||||||
import dev.fyloz.colorrecipesexplorer.config.annotations.LogicComponent
|
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.RecipeGroupInformationDto
|
||||||
import dev.fyloz.colorrecipesexplorer.dtos.RecipeStepDto
|
import dev.fyloz.colorrecipesexplorer.dtos.RecipeStepDto
|
||||||
import dev.fyloz.colorrecipesexplorer.exception.InvalidPositionError
|
import dev.fyloz.colorrecipesexplorer.exception.InvalidPositionError
|
||||||
import dev.fyloz.colorrecipesexplorer.exception.InvalidPositionsException
|
import dev.fyloz.colorrecipesexplorer.exception.InvalidPositionsException
|
||||||
import dev.fyloz.colorrecipesexplorer.exception.RestException
|
import dev.fyloz.colorrecipesexplorer.exception.RestException
|
||||||
import dev.fyloz.colorrecipesexplorer.model.account.Group
|
|
||||||
import dev.fyloz.colorrecipesexplorer.service.RecipeStepService
|
import dev.fyloz.colorrecipesexplorer.service.RecipeStepService
|
||||||
import dev.fyloz.colorrecipesexplorer.utils.PositionUtils
|
import dev.fyloz.colorrecipesexplorer.utils.PositionUtils
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
|
@ -46,4 +45,4 @@ class InvalidGroupStepsPositionsException(
|
||||||
) {
|
) {
|
||||||
val errors: Set<InvalidPositionError>
|
val errors: Set<InvalidPositionError>
|
||||||
get() = exception.errors
|
get() = exception.errors
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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.SpringUserDetails
|
||||||
import dev.fyloz.colorrecipesexplorer.SpringUserDetailsService
|
import dev.fyloz.colorrecipesexplorer.SpringUserDetailsService
|
||||||
import dev.fyloz.colorrecipesexplorer.config.annotations.RequireDatabase
|
import dev.fyloz.colorrecipesexplorer.config.annotations.RequireDatabase
|
||||||
import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties
|
import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties
|
||||||
import dev.fyloz.colorrecipesexplorer.dtos.UserDetails
|
import dev.fyloz.colorrecipesexplorer.dtos.account.UserDetails
|
||||||
import dev.fyloz.colorrecipesexplorer.dtos.UserDto
|
import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto
|
||||||
import dev.fyloz.colorrecipesexplorer.exception.NotFoundException
|
import dev.fyloz.colorrecipesexplorer.exception.NotFoundException
|
||||||
import dev.fyloz.colorrecipesexplorer.model.account.Permission
|
import dev.fyloz.colorrecipesexplorer.model.account.Permission
|
||||||
import dev.fyloz.colorrecipesexplorer.model.account.User
|
import dev.fyloz.colorrecipesexplorer.model.account.User
|
||||||
|
@ -15,7 +15,7 @@ import org.springframework.stereotype.Service
|
||||||
|
|
||||||
interface UserDetailsLogic : SpringUserDetailsService {
|
interface UserDetailsLogic : SpringUserDetailsService {
|
||||||
/** Loads an [User] for the given [id]. */
|
/** Loads an [User] for the given [id]. */
|
||||||
fun loadUserById(id: Long, isDefaultGroupUser: Boolean = true): UserDetails
|
fun loadUserById(id: Long): UserDetails
|
||||||
}
|
}
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
|
@ -25,18 +25,14 @@ class DefaultUserDetailsLogic(
|
||||||
) : UserDetailsLogic {
|
) : UserDetailsLogic {
|
||||||
override fun loadUserByUsername(username: String): UserDetails {
|
override fun loadUserByUsername(username: String): UserDetails {
|
||||||
try {
|
try {
|
||||||
return loadUserById(username.toLong(), false)
|
return loadUserById(username.toLong())
|
||||||
} catch (ex: NotFoundException) {
|
} catch (ex: NotFoundException) {
|
||||||
throw UsernameNotFoundException(username)
|
throw UsernameNotFoundException(username)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun loadUserById(id: Long, isDefaultGroupUser: Boolean): UserDetails {
|
override fun loadUserById(id: Long): UserDetails {
|
||||||
val user = userLogic.getById(
|
val user = userLogic.getById(id, isSystemUser = true)
|
||||||
id,
|
|
||||||
isSystemUser = true,
|
|
||||||
isDefaultGroupUser = isDefaultGroupUser
|
|
||||||
)
|
|
||||||
return UserDetails(user)
|
return UserDetails(user)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -69,10 +65,10 @@ class EmergencyUserDetailsLogic(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun loadUserByUsername(username: String): SpringUserDetails {
|
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 }
|
val user = users.firstOrNull { it.id == id }
|
||||||
?: throw UsernameNotFoundException(id.toString())
|
?: throw UsernameNotFoundException(id.toString())
|
||||||
|
|
|
@ -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.Constants
|
||||||
import dev.fyloz.colorrecipesexplorer.config.annotations.LogicComponent
|
import dev.fyloz.colorrecipesexplorer.config.annotations.LogicComponent
|
||||||
import dev.fyloz.colorrecipesexplorer.config.security.authorizationCookieName
|
import dev.fyloz.colorrecipesexplorer.config.security.filters.blacklistedJwtTokens
|
||||||
import dev.fyloz.colorrecipesexplorer.config.security.blacklistedJwtTokens
|
import dev.fyloz.colorrecipesexplorer.dtos.account.GroupDto
|
||||||
import dev.fyloz.colorrecipesexplorer.dtos.GroupDto
|
import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto
|
||||||
import dev.fyloz.colorrecipesexplorer.dtos.UserDto
|
import dev.fyloz.colorrecipesexplorer.dtos.account.UserSaveDto
|
||||||
import dev.fyloz.colorrecipesexplorer.dtos.UserSaveDto
|
import dev.fyloz.colorrecipesexplorer.dtos.account.UserUpdateDto
|
||||||
import dev.fyloz.colorrecipesexplorer.dtos.UserUpdateDto
|
|
||||||
import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException
|
import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException
|
||||||
import dev.fyloz.colorrecipesexplorer.logic.BaseLogic
|
import dev.fyloz.colorrecipesexplorer.logic.BaseLogic
|
||||||
import dev.fyloz.colorrecipesexplorer.logic.Logic
|
import dev.fyloz.colorrecipesexplorer.logic.Logic
|
||||||
import dev.fyloz.colorrecipesexplorer.model.account.Permission
|
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.context.annotation.Lazy
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
|
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder
|
import org.springframework.security.crypto.password.PasswordEncoder
|
||||||
import org.springframework.web.util.WebUtils
|
import org.springframework.web.util.WebUtils
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
@ -25,13 +23,7 @@ interface UserLogic : Logic<UserDto, UserService> {
|
||||||
fun getAllByGroup(group: GroupDto): Collection<UserDto>
|
fun getAllByGroup(group: GroupDto): Collection<UserDto>
|
||||||
|
|
||||||
/** Gets the user with the given [id]. */
|
/** Gets the user with the given [id]. */
|
||||||
fun getById(id: Long, isSystemUser: Boolean, isDefaultGroupUser: Boolean): UserDto
|
fun getById(id: Long, isSystemUser: 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)
|
|
||||||
|
|
||||||
/** Saves the given [dto]. */
|
/** Saves the given [dto]. */
|
||||||
fun save(dto: UserSaveDto): UserDto
|
fun save(dto: UserSaveDto): UserDto
|
||||||
|
@ -59,30 +51,13 @@ interface UserLogic : Logic<UserDto, UserService> {
|
||||||
class DefaultUserLogic(
|
class DefaultUserLogic(
|
||||||
service: UserService, @Lazy private val groupLogic: GroupLogic, @Lazy private val passwordEncoder: PasswordEncoder
|
service: UserService, @Lazy private val groupLogic: GroupLogic, @Lazy private val passwordEncoder: PasswordEncoder
|
||||||
) : BaseLogic<UserDto, UserService>(service, Constants.ModelNames.USER), UserLogic {
|
) : 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 getAllByGroup(group: GroupDto) = service.getAllByGroup(group)
|
||||||
|
|
||||||
override fun getById(id: Long) = getById(id, isSystemUser = false, isDefaultGroupUser = false)
|
override fun getById(id: Long) = getById(id, false)
|
||||||
override fun getById(id: Long, isSystemUser: Boolean, isDefaultGroupUser: Boolean) =
|
override fun getById(id: Long, isSystemUser: Boolean) =
|
||||||
service.getById(id, !isDefaultGroupUser, !isSystemUser) ?: throw notFoundException(value = id)
|
service.getById(id, 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 save(dto: UserSaveDto) = save(
|
override fun save(dto: UserSaveDto) = save(
|
||||||
UserDto(
|
UserDto(
|
||||||
|
@ -92,8 +67,7 @@ class DefaultUserLogic(
|
||||||
password = passwordEncoder.encode(dto.password),
|
password = passwordEncoder.encode(dto.password),
|
||||||
group = if (dto.groupId != null) groupLogic.getById(dto.groupId) else null,
|
group = if (dto.groupId != null) groupLogic.getById(dto.groupId) else null,
|
||||||
permissions = dto.permissions,
|
permissions = dto.permissions,
|
||||||
isSystemUser = dto.isSystemUser,
|
isSystemUser = dto.isSystemUser
|
||||||
isDefaultGroupUser = dto.isDefaultGroupUser
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -105,7 +79,7 @@ class DefaultUserLogic(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun update(dto: UserUpdateDto): UserDto {
|
override fun update(dto: UserUpdateDto): UserDto {
|
||||||
val user = getById(dto.id, isSystemUser = false, isDefaultGroupUser = false)
|
val user = getById(dto.id)
|
||||||
|
|
||||||
return update(
|
return update(
|
||||||
user.copy(
|
user.copy(
|
||||||
|
@ -123,7 +97,7 @@ class DefaultUserLogic(
|
||||||
return super.update(dto)
|
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))
|
update(this.copy(lastLoginTime = time))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -140,7 +114,7 @@ class DefaultUserLogic(
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun logout(request: HttpServletRequest) {
|
override fun logout(request: HttpServletRequest) {
|
||||||
val authorizationCookie = WebUtils.getCookie(request, authorizationCookieName)
|
val authorizationCookie = WebUtils.getCookie(request, Constants.CookieNames.AUTHORIZATION)
|
||||||
if (authorizationCookie != null) {
|
if (authorizationCookie != null) {
|
||||||
val authorizationToken = authorizationCookie.value
|
val authorizationToken = authorizationCookie.value
|
||||||
if (authorizationToken != null && authorizationToken.startsWith("Bearer")) {
|
if (authorizationToken != null && authorizationToken.startsWith("Bearer")) {
|
||||||
|
@ -166,4 +140,4 @@ class DefaultUserLogic(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
}
|
|
|
@ -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
|
||||||
|
)
|
|
@ -4,32 +4,34 @@ import org.springframework.security.core.GrantedAuthority
|
||||||
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||||
|
|
||||||
enum class Permission(
|
enum class Permission(
|
||||||
val impliedPermissions: List<Permission> = listOf(),
|
val id: Int,
|
||||||
|
private val impliedPermissions: List<Permission> = listOf(),
|
||||||
val deprecated: Boolean = false
|
val deprecated: Boolean = false
|
||||||
) {
|
) {
|
||||||
READ_FILE,
|
READ_FILE(0),
|
||||||
WRITE_FILE(listOf(READ_FILE)),
|
WRITE_FILE(1, listOf(READ_FILE)),
|
||||||
|
|
||||||
VIEW_RECIPES(listOf(READ_FILE)),
|
VIEW_RECIPES(2, listOf(READ_FILE)),
|
||||||
VIEW_CATALOG(listOf(READ_FILE)),
|
VIEW_CATALOG(3, listOf(READ_FILE)),
|
||||||
VIEW_USERS,
|
VIEW_USERS(4),
|
||||||
|
|
||||||
EDIT_RECIPES_PUBLIC_DATA(listOf(VIEW_RECIPES)),
|
EDIT_RECIPES_PUBLIC_DATA(5, listOf(VIEW_RECIPES)),
|
||||||
EDIT_RECIPES(listOf(EDIT_RECIPES_PUBLIC_DATA, WRITE_FILE)),
|
EDIT_RECIPES(6, listOf(EDIT_RECIPES_PUBLIC_DATA, WRITE_FILE)),
|
||||||
EDIT_MATERIALS(listOf(VIEW_CATALOG, WRITE_FILE)),
|
EDIT_MATERIALS(7, listOf(VIEW_CATALOG, WRITE_FILE)),
|
||||||
EDIT_MATERIAL_TYPES(listOf(VIEW_CATALOG)),
|
EDIT_MATERIAL_TYPES(8, listOf(VIEW_CATALOG)),
|
||||||
EDIT_COMPANIES(listOf(VIEW_CATALOG)),
|
EDIT_COMPANIES(9, listOf(VIEW_CATALOG)),
|
||||||
EDIT_USERS(listOf(VIEW_USERS)),
|
EDIT_USERS(10, listOf(VIEW_USERS)),
|
||||||
EDIT_CATALOG(listOf(EDIT_MATERIALS, EDIT_MATERIAL_TYPES, EDIT_COMPANIES)),
|
EDIT_CATALOG(11, listOf(EDIT_MATERIALS, EDIT_MATERIAL_TYPES, EDIT_COMPANIES)),
|
||||||
|
|
||||||
VIEW_TOUCH_UP_KITS,
|
VIEW_TOUCH_UP_KITS(12),
|
||||||
EDIT_TOUCH_UP_KITS(listOf(VIEW_TOUCH_UP_KITS)),
|
EDIT_TOUCH_UP_KITS(13, listOf(VIEW_TOUCH_UP_KITS)),
|
||||||
|
|
||||||
PRINT_MIXES(listOf(VIEW_RECIPES)),
|
PRINT_MIXES(14, listOf(VIEW_RECIPES)),
|
||||||
ADD_TO_INVENTORY(listOf(VIEW_CATALOG)),
|
ADD_TO_INVENTORY(15, listOf(VIEW_CATALOG)),
|
||||||
DEDUCT_FROM_INVENTORY(listOf(VIEW_RECIPES)),
|
DEDUCT_FROM_INVENTORY(16, listOf(VIEW_RECIPES)),
|
||||||
|
|
||||||
ADMIN(
|
ADMIN(
|
||||||
|
17,
|
||||||
listOf(
|
listOf(
|
||||||
EDIT_RECIPES,
|
EDIT_RECIPES,
|
||||||
EDIT_CATALOG,
|
EDIT_CATALOG,
|
||||||
|
@ -44,58 +46,58 @@ enum class Permission(
|
||||||
),
|
),
|
||||||
|
|
||||||
// deprecated permissions
|
// deprecated permissions
|
||||||
VIEW_RECIPE(listOf(VIEW_RECIPES), true),
|
VIEW_RECIPE(101, listOf(VIEW_RECIPES), true),
|
||||||
VIEW_MATERIAL(listOf(VIEW_CATALOG), true),
|
VIEW_MATERIAL(102, listOf(VIEW_CATALOG), true),
|
||||||
VIEW_MATERIAL_TYPE(listOf(VIEW_CATALOG), true),
|
VIEW_MATERIAL_TYPE(103, listOf(VIEW_CATALOG), true),
|
||||||
VIEW_COMPANY(listOf(VIEW_CATALOG), true),
|
VIEW_COMPANY(104, listOf(VIEW_CATALOG), true),
|
||||||
VIEW(listOf(VIEW_RECIPES, VIEW_CATALOG), true),
|
VIEW(105, listOf(VIEW_RECIPES, VIEW_CATALOG), true),
|
||||||
VIEW_EMPLOYEE(listOf(VIEW_USERS), true),
|
VIEW_EMPLOYEE(106, listOf(VIEW_USERS), true),
|
||||||
VIEW_EMPLOYEE_GROUP(listOf(VIEW_USERS), true),
|
VIEW_EMPLOYEE_GROUP(107, listOf(VIEW_USERS), true),
|
||||||
|
|
||||||
EDIT_RECIPE(listOf(EDIT_RECIPES), true),
|
EDIT_RECIPE(108, listOf(EDIT_RECIPES), true),
|
||||||
EDIT_MATERIAL(listOf(EDIT_MATERIALS), true),
|
EDIT_MATERIAL(109, listOf(EDIT_MATERIALS), true),
|
||||||
EDIT_MATERIAL_TYPE(listOf(EDIT_MATERIAL_TYPES), true),
|
EDIT_MATERIAL_TYPE(110, listOf(EDIT_MATERIAL_TYPES), true),
|
||||||
EDIT_COMPANY(listOf(EDIT_COMPANIES), true),
|
EDIT_COMPANY(111, listOf(EDIT_COMPANIES), true),
|
||||||
EDIT(listOf(EDIT_RECIPES, EDIT_CATALOG), true),
|
EDIT(112, listOf(EDIT_RECIPES, EDIT_CATALOG), true),
|
||||||
EDIT_EMPLOYEE(listOf(EDIT_USERS), true),
|
EDIT_EMPLOYEE(113, listOf(EDIT_USERS), true),
|
||||||
EDIT_EMPLOYEE_PASSWORD(listOf(EDIT_USERS), true),
|
EDIT_EMPLOYEE_PASSWORD(114, listOf(EDIT_USERS), true),
|
||||||
EDIT_EMPLOYEE_GROUP(listOf(EDIT_USERS), true),
|
EDIT_EMPLOYEE_GROUP(115, listOf(EDIT_USERS), true),
|
||||||
|
|
||||||
REMOVE_FILE(listOf(WRITE_FILE), true),
|
REMOVE_FILE(116, listOf(WRITE_FILE), true),
|
||||||
GENERATE_TOUCH_UP_KIT(listOf(VIEW_TOUCH_UP_KITS), true),
|
GENERATE_TOUCH_UP_KIT(117, listOf(VIEW_TOUCH_UP_KITS), true),
|
||||||
|
|
||||||
REMOVE_RECIPES(listOf(EDIT_RECIPES, REMOVE_FILE), true),
|
REMOVE_RECIPES(118, listOf(EDIT_RECIPES, REMOVE_FILE), true),
|
||||||
REMOVE_MATERIALS(listOf(EDIT_MATERIALS, REMOVE_FILE), true),
|
REMOVE_MATERIALS(119, listOf(EDIT_MATERIALS, REMOVE_FILE), true),
|
||||||
REMOVE_MATERIAL_TYPES(listOf(EDIT_MATERIAL_TYPES), true),
|
REMOVE_MATERIAL_TYPES(120, listOf(EDIT_MATERIAL_TYPES), true),
|
||||||
REMOVE_COMPANIES(listOf(EDIT_COMPANIES), true),
|
REMOVE_COMPANIES(121, listOf(EDIT_COMPANIES), true),
|
||||||
REMOVE_USERS(listOf(EDIT_USERS), true),
|
REMOVE_USERS(122, listOf(EDIT_USERS), true),
|
||||||
REMOVE_CATALOG(listOf(REMOVE_MATERIALS, REMOVE_MATERIAL_TYPES, REMOVE_COMPANIES), true),
|
REMOVE_CATALOG(123, listOf(REMOVE_MATERIALS, REMOVE_MATERIAL_TYPES, REMOVE_COMPANIES), true),
|
||||||
|
|
||||||
REMOVE_RECIPE(listOf(REMOVE_RECIPES), true),
|
REMOVE_RECIPE(124, listOf(REMOVE_RECIPES), true),
|
||||||
REMOVE_MATERIAL(listOf(REMOVE_MATERIALS), true),
|
REMOVE_MATERIAL(125, listOf(REMOVE_MATERIALS), true),
|
||||||
REMOVE_MATERIAL_TYPE(listOf(REMOVE_MATERIAL_TYPES), true),
|
REMOVE_MATERIAL_TYPE(126, listOf(REMOVE_MATERIAL_TYPES), true),
|
||||||
REMOVE_COMPANY(listOf(REMOVE_COMPANIES), true),
|
REMOVE_COMPANY(127, listOf(REMOVE_COMPANIES), true),
|
||||||
REMOVE(listOf(REMOVE_RECIPES, REMOVE_CATALOG), true),
|
REMOVE(128, listOf(REMOVE_RECIPES, REMOVE_CATALOG), true),
|
||||||
REMOVE_EMPLOYEE(listOf(REMOVE_USERS), true),
|
REMOVE_EMPLOYEE(129, listOf(REMOVE_USERS), true),
|
||||||
REMOVE_EMPLOYEE_GROUP(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 {
|
operator fun contains(permission: Permission): Boolean {
|
||||||
return permission == this || impliedPermissions.any { permission in it }
|
return permission == this || impliedPermissions.any { permission in it }
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
fun Permission.flat(): Iterable<Permission> {
|
fun flat(): Iterable<Permission> {
|
||||||
return mutableSetOf(this).apply {
|
return mutableSetOf(this).apply {
|
||||||
impliedPermissions.forEach {
|
impliedPermissions.forEach {
|
||||||
addAll(it.flat())
|
addAll(it.flat())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/** Converts the given [Permission] to a [GrantedAuthority]. */
|
/** Converts the given permission to a [GrantedAuthority]. */
|
||||||
fun Permission.toAuthority(): GrantedAuthority {
|
fun toAuthority(): GrantedAuthority {
|
||||||
return SimpleGrantedAuthority(name)
|
return SimpleGrantedAuthority(name)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,26 +1,34 @@
|
||||||
package dev.fyloz.colorrecipesexplorer.repository
|
package dev.fyloz.colorrecipesexplorer.repository
|
||||||
|
|
||||||
import dev.fyloz.colorrecipesexplorer.model.account.Group
|
import dev.fyloz.colorrecipesexplorer.model.account.Group
|
||||||
|
import dev.fyloz.colorrecipesexplorer.model.account.GroupToken
|
||||||
import dev.fyloz.colorrecipesexplorer.model.account.User
|
import dev.fyloz.colorrecipesexplorer.model.account.User
|
||||||
import org.springframework.data.jpa.repository.JpaRepository
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
import org.springframework.data.jpa.repository.Query
|
import org.springframework.data.jpa.repository.Query
|
||||||
import org.springframework.stereotype.Repository
|
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
|
@Repository
|
||||||
interface UserRepository : JpaRepository<User, Long> {
|
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. */
|
/** 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]. */
|
/** 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")
|
@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>
|
fun findAllByGroup(group: Group): Collection<User>
|
||||||
|
|
||||||
/** Finds the user with the given [firstName] and [lastName]. */
|
/** 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?
|
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
|
@Repository
|
||||||
|
@ -28,3 +36,15 @@ interface GroupRepository : JpaRepository<Group, Long> {
|
||||||
/** Checks if a group with the given [name] and a different [id] exists. */
|
/** Checks if a group with the given [name] and a different [id] exists. */
|
||||||
fun existsByNameAndIdNot(name: String, id: Long): Boolean
|
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?
|
||||||
|
}
|
||||||
|
|
|
@ -4,7 +4,6 @@ import dev.fyloz.colorrecipesexplorer.logic.config.ConfigurationLogic
|
||||||
import dev.fyloz.colorrecipesexplorer.model.ConfigurationBase
|
import dev.fyloz.colorrecipesexplorer.model.ConfigurationBase
|
||||||
import dev.fyloz.colorrecipesexplorer.model.ConfigurationDto
|
import dev.fyloz.colorrecipesexplorer.model.ConfigurationDto
|
||||||
import dev.fyloz.colorrecipesexplorer.model.account.Permission
|
import dev.fyloz.colorrecipesexplorer.model.account.Permission
|
||||||
import dev.fyloz.colorrecipesexplorer.model.account.toAuthority
|
|
||||||
import dev.fyloz.colorrecipesexplorer.restartApplication
|
import dev.fyloz.colorrecipesexplorer.restartApplication
|
||||||
import org.springframework.http.MediaType
|
import org.springframework.http.MediaType
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,21 +1,21 @@
|
||||||
package dev.fyloz.colorrecipesexplorer.rest
|
package dev.fyloz.colorrecipesexplorer.rest.account
|
||||||
|
|
||||||
import dev.fyloz.colorrecipesexplorer.Constants
|
import dev.fyloz.colorrecipesexplorer.Constants
|
||||||
import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeEditUsers
|
import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeEditUsers
|
||||||
import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeViewUsers
|
import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeViewUsers
|
||||||
import dev.fyloz.colorrecipesexplorer.dtos.GroupDto
|
import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto
|
||||||
import dev.fyloz.colorrecipesexplorer.dtos.UserDto
|
import dev.fyloz.colorrecipesexplorer.dtos.account.UserSaveDto
|
||||||
import dev.fyloz.colorrecipesexplorer.dtos.UserSaveDto
|
import dev.fyloz.colorrecipesexplorer.dtos.account.UserUpdateDto
|
||||||
import dev.fyloz.colorrecipesexplorer.dtos.UserUpdateDto
|
import dev.fyloz.colorrecipesexplorer.logic.account.UserLogic
|
||||||
import dev.fyloz.colorrecipesexplorer.logic.users.GroupLogic
|
|
||||||
import dev.fyloz.colorrecipesexplorer.logic.users.UserLogic
|
|
||||||
import dev.fyloz.colorrecipesexplorer.model.account.Permission
|
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.context.annotation.Profile
|
||||||
import org.springframework.http.MediaType
|
import org.springframework.http.MediaType
|
||||||
import org.springframework.security.access.prepost.PreAuthorize
|
import org.springframework.security.access.prepost.PreAuthorize
|
||||||
import org.springframework.web.bind.annotation.*
|
import org.springframework.web.bind.annotation.*
|
||||||
import javax.servlet.http.HttpServletRequest
|
import javax.servlet.http.HttpServletRequest
|
||||||
import javax.servlet.http.HttpServletResponse
|
|
||||||
import javax.validation.Valid
|
import javax.validation.Valid
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
|
@ -78,74 +78,10 @@ class UserController(private val userLogic: UserLogic) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping(Constants.ControllerPaths.GROUP)
|
@RequestMapping(Constants.ControllerPaths.LOGOUT)
|
||||||
@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")
|
|
||||||
@Profile("!emergency")
|
@Profile("!emergency")
|
||||||
class LogoutController(private val userLogic: UserLogic) {
|
class LogoutController(private val userLogic: UserLogic) {
|
||||||
@GetMapping("logout")
|
@GetMapping
|
||||||
@PreAuthorize("isFullyAuthenticated()")
|
@PreAuthorize("isFullyAuthenticated()")
|
||||||
fun logout(request: HttpServletRequest) =
|
fun logout(request: HttpServletRequest) =
|
||||||
ok {
|
ok {
|
|
@ -8,6 +8,7 @@ import dev.fyloz.colorrecipesexplorer.model.ConfigurationType
|
||||||
import dev.fyloz.colorrecipesexplorer.model.Recipe
|
import dev.fyloz.colorrecipesexplorer.model.Recipe
|
||||||
import dev.fyloz.colorrecipesexplorer.model.RecipeGroupInformation
|
import dev.fyloz.colorrecipesexplorer.model.RecipeGroupInformation
|
||||||
import dev.fyloz.colorrecipesexplorer.repository.RecipeRepository
|
import dev.fyloz.colorrecipesexplorer.repository.RecipeRepository
|
||||||
|
import dev.fyloz.colorrecipesexplorer.service.account.GroupService
|
||||||
import dev.fyloz.colorrecipesexplorer.utils.collections.lazyMap
|
import dev.fyloz.colorrecipesexplorer.utils.collections.lazyMap
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
|
@ -89,4 +90,4 @@ class DefaultRecipeService(
|
||||||
with(Period.parse(configLogic.getContent(ConfigurationType.RECIPE_APPROBATION_EXPIRATION))) {
|
with(Period.parse(configLogic.getContent(ConfigurationType.RECIPE_APPROBATION_EXPIRATION))) {
|
||||||
recipe.approbationDate?.plus(this)?.isBefore(LocalDate.now())
|
recipe.approbationDate?.plus(this)?.isBefore(LocalDate.now())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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.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.Group
|
||||||
import dev.fyloz.colorrecipesexplorer.model.account.Permission
|
import dev.fyloz.colorrecipesexplorer.model.account.Permission
|
||||||
import dev.fyloz.colorrecipesexplorer.model.account.flat
|
|
||||||
import dev.fyloz.colorrecipesexplorer.repository.GroupRepository
|
import dev.fyloz.colorrecipesexplorer.repository.GroupRepository
|
||||||
|
import dev.fyloz.colorrecipesexplorer.service.BaseService
|
||||||
|
import dev.fyloz.colorrecipesexplorer.service.Service
|
||||||
|
|
||||||
interface GroupService : Service<GroupDto, Group, GroupRepository> {
|
interface GroupService : Service<GroupDto, Group, GroupRepository> {
|
||||||
/** Checks if a group with the given [name] and a different [id] exists. */
|
/** 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) =
|
override fun flattenPermissions(group: Group) =
|
||||||
group.permissions.flatMap { it.flat() }.filter { !it.deprecated }
|
group.permissions.flatMap { it.flat() }.filter { !it.deprecated }
|
||||||
}
|
}
|
|
@ -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))
|
||||||
|
}
|
|
@ -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.config.annotations.ServiceComponent
|
||||||
import dev.fyloz.colorrecipesexplorer.dtos.GroupDto
|
import dev.fyloz.colorrecipesexplorer.dtos.account.GroupDto
|
||||||
import dev.fyloz.colorrecipesexplorer.dtos.UserDto
|
import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto
|
||||||
import dev.fyloz.colorrecipesexplorer.model.account.Permission
|
import dev.fyloz.colorrecipesexplorer.model.account.Permission
|
||||||
import dev.fyloz.colorrecipesexplorer.model.account.User
|
import dev.fyloz.colorrecipesexplorer.model.account.User
|
||||||
import dev.fyloz.colorrecipesexplorer.model.account.flat
|
|
||||||
import dev.fyloz.colorrecipesexplorer.repository.UserRepository
|
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> {
|
interface UserService : Service<UserDto, User, UserRepository> {
|
||||||
/** Checks if a user with the given [firstName] and [lastName] exists. */
|
/** Checks if a user with the given [firstName] and [lastName] exists. */
|
||||||
fun existsByFirstNameAndLastName(firstName: String, lastName: String, id: Long? = null): Boolean
|
fun existsByFirstNameAndLastName(firstName: String, lastName: String, id: Long? = null): Boolean
|
||||||
|
|
||||||
/** Gets all users, depending on [isSystemUser] and [isDefaultGroupUser]. */
|
/** Gets all users, depending on [isSystemUser]. */
|
||||||
fun getAll(isSystemUser: Boolean, isDefaultGroupUser: Boolean): Collection<UserDto>
|
fun getAll(isSystemUser: Boolean): Collection<UserDto>
|
||||||
|
|
||||||
/** Gets all users for the given [group]. */
|
/** Gets all users for the given [group]. */
|
||||||
fun getAllByGroup(group: GroupDto): Collection<UserDto>
|
fun getAllByGroup(group: GroupDto): Collection<UserDto>
|
||||||
|
|
||||||
/** Finds the user with the given [id], depending on [isSystemUser] and [isDefaultGroupUser]. */
|
/** Finds the user with the given [id], depending on [isSystemUser]. */
|
||||||
fun getById(id: Long, isSystemUser: Boolean, isDefaultGroupUser: Boolean): UserDto?
|
fun getById(id: Long, isSystemUser: Boolean): UserDto?
|
||||||
|
|
||||||
/** Finds the user with the given [firstName] and [lastName]. */
|
/** Finds the user with the given [firstName] and [lastName]. */
|
||||||
fun getByFirstNameAndLastName(firstName: String, lastName: String): UserDto?
|
fun getByFirstNameAndLastName(firstName: String, lastName: String): UserDto?
|
||||||
|
|
||||||
/** Find the default user for the given [group]. */
|
|
||||||
fun getDefaultGroupUser(group: GroupDto): UserDto?
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@ServiceComponent
|
@ServiceComponent
|
||||||
class DefaultUserService(repository: UserRepository, private val groupService: GroupService) :
|
class DefaultUserService(repository: UserRepository, private val groupService: GroupService) :
|
||||||
BaseService<UserDto, User, UserRepository>(repository), UserService {
|
BaseService<UserDto, User, UserRepository>(repository), UserService {
|
||||||
override fun existsByFirstNameAndLastName(firstName: String, lastName: String, id: Long?) =
|
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) =
|
override fun getAll(isSystemUser: Boolean) =
|
||||||
repository.findAll()
|
repository.findAllByIsDefaultGroupUserIsFalse()
|
||||||
.filter { isSystemUser || !it.isSystemUser }
|
.filter { isSystemUser || !it.isSystemUser }
|
||||||
.filter { isDefaultGroupUser || !it.isDefaultGroupUser }
|
|
||||||
.map(::toDto)
|
.map(::toDto)
|
||||||
|
|
||||||
override fun getAllByGroup(group: GroupDto) =
|
override fun getAllByGroup(group: GroupDto) =
|
||||||
repository.findAllByGroup(groupService.toEntity(group))
|
repository.findAllByGroup(groupService.toEntity(group))
|
||||||
.map(::toDto)
|
.map(::toDto)
|
||||||
|
|
||||||
override fun getById(id: Long, isSystemUser: Boolean, isDefaultGroupUser: Boolean): UserDto? {
|
override fun getById(id: Long, isSystemUser: Boolean): UserDto? {
|
||||||
val user = repository.findByIdOrNull(id) ?: return null
|
val user = repository.findByIdAndIsDefaultGroupUserIsFalse(id) ?: return null
|
||||||
if ((!isSystemUser && user.isSystemUser) ||
|
if (!isSystemUser && user.isSystemUser) {
|
||||||
!isDefaultGroupUser && user.isDefaultGroupUser
|
|
||||||
) {
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -61,11 +55,6 @@ class DefaultUserService(repository: UserRepository, private val groupService: G
|
||||||
return if (user != null) toDto(user) else null
|
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(
|
override fun toDto(entity: User) = UserDto(
|
||||||
entity.id,
|
entity.id,
|
||||||
entity.firstName,
|
entity.firstName,
|
||||||
|
@ -75,7 +64,6 @@ class DefaultUserService(repository: UserRepository, private val groupService: G
|
||||||
getFlattenPermissions(entity),
|
getFlattenPermissions(entity),
|
||||||
entity.permissions,
|
entity.permissions,
|
||||||
entity.lastLoginTime,
|
entity.lastLoginTime,
|
||||||
entity.isDefaultGroupUser,
|
|
||||||
entity.isSystemUser
|
entity.isSystemUser
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -84,7 +72,7 @@ class DefaultUserService(repository: UserRepository, private val groupService: G
|
||||||
dto.firstName,
|
dto.firstName,
|
||||||
dto.lastName,
|
dto.lastName,
|
||||||
dto.password,
|
dto.password,
|
||||||
dto.isDefaultGroupUser,
|
false,
|
||||||
dto.isSystemUser,
|
dto.isSystemUser,
|
||||||
if (dto.group != null) groupService.toEntity(dto.group) else null,
|
if (dto.group != null) groupService.toEntity(dto.group) else null,
|
||||||
dto.explicitPermissions,
|
dto.explicitPermissions,
|
||||||
|
@ -98,6 +86,6 @@ class DefaultUserService(repository: UserRepository, private val groupService: G
|
||||||
return perms + groupService.flattenPermissions(user.group)
|
return perms + groupService.flattenPermissions(user.group)
|
||||||
}
|
}
|
||||||
|
|
||||||
return perms
|
return perms.distinctBy { it.id }
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,31 +1,40 @@
|
||||||
package dev.fyloz.colorrecipesexplorer.utils
|
package dev.fyloz.colorrecipesexplorer.utils
|
||||||
|
|
||||||
|
import dev.fyloz.colorrecipesexplorer.Constants
|
||||||
import javax.servlet.http.HttpServletResponse
|
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(
|
data class CookieBuilderOptions(
|
||||||
/** HTTP Only cookies cannot be access by Javascript clients. */
|
/** 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. */
|
/** 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. */
|
/** Secure cookies are only sent in HTTPS requests. */
|
||||||
var secure: Boolean = defaultCookieSecure,
|
var secure: Boolean = DEFAULT_SECURE,
|
||||||
|
|
||||||
/** Cookie's maximum age in seconds. */
|
/** 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) {
|
private enum class CookieBuilderOption(val optionName: String) {
|
||||||
HTTP_ONLY("HttpOnly"),
|
HTTP_ONLY("HttpOnly"),
|
||||||
SAME_SITE("SameSite"),
|
SAME_SITE("SameSite"),
|
||||||
SECURE("Secure"),
|
SECURE("Secure"),
|
||||||
MAX_AGE("Max-Age")
|
MAX_AGE("Max-Age"),
|
||||||
|
PATH("Path")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun HttpServletResponse.addCookie(name: String, value: String, optionsBuilder: CookieBuilderOptions.() -> Unit) {
|
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;")
|
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.SAME_SITE, options.sameSite)
|
||||||
addBoolOption(CookieBuilderOption.SECURE, options.secure)
|
addBoolOption(CookieBuilderOption.SECURE, options.secure)
|
||||||
addOption(CookieBuilderOption.MAX_AGE, options.maxAge)
|
addOption(CookieBuilderOption.MAX_AGE, options.maxAge)
|
||||||
|
addOption(CookieBuilderOption.PATH, options.path)
|
||||||
|
|
||||||
return cookie.toString()
|
return cookie.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun parseBearer(source: String) =
|
||||||
|
source.replace(Constants.BEARER_PREFIX, "").trim()
|
|
@ -1 +1 @@
|
||||||
spring.jpa.show-sql=true
|
spring.jpa.show-sql=false
|
||||||
|
|
|
@ -1,100 +1,122 @@
|
||||||
package dev.fyloz.colorrecipesexplorer.logic
|
package dev.fyloz.colorrecipesexplorer.logic
|
||||||
|
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
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.config.properties.CreSecurityProperties
|
||||||
import dev.fyloz.colorrecipesexplorer.dtos.UserDetails
|
import dev.fyloz.colorrecipesexplorer.dtos.account.UserDetails
|
||||||
import dev.fyloz.colorrecipesexplorer.dtos.UserDto
|
import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto
|
||||||
import dev.fyloz.colorrecipesexplorer.logic.users.DefaultJwtLogic
|
import dev.fyloz.colorrecipesexplorer.logic.account.DefaultJwtLogic
|
||||||
import dev.fyloz.colorrecipesexplorer.logic.users.jwtClaimUser
|
import dev.fyloz.colorrecipesexplorer.model.account.Permission
|
||||||
import dev.fyloz.colorrecipesexplorer.utils.base64encode
|
|
||||||
import dev.fyloz.colorrecipesexplorer.utils.isAround
|
|
||||||
import io.jsonwebtoken.Jwts
|
|
||||||
import io.jsonwebtoken.jackson.io.JacksonDeserializer
|
|
||||||
import io.mockk.clearAllMocks
|
import io.mockk.clearAllMocks
|
||||||
import io.mockk.spyk
|
import io.mockk.spyk
|
||||||
import org.junit.jupiter.api.AfterEach
|
import org.junit.jupiter.api.AfterEach
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import java.time.Instant
|
import java.util.*
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
import kotlin.test.assertTrue
|
import kotlin.test.assertFalse
|
||||||
|
|
||||||
class DefaultJwtLogicTest {
|
class DefaultJwtLogicTest {
|
||||||
private val objectMapper = jacksonObjectMapper()
|
private val objectMapper = jacksonObjectMapper()
|
||||||
private val securityProperties = CreSecurityProperties().apply {
|
private val securityProperties = CreSecurityProperties().apply {
|
||||||
jwtSecret = "XRRm7OflmFuCrOB2Xvmfsercih9DCKom"
|
jwtSecret = "exBwMbD9Jw7YF7HYpwXQjcsPf4SrRSSF5YTvgbj0"
|
||||||
jwtDuration = 1000000L
|
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
|
@AfterEach
|
||||||
internal fun afterEach() {
|
internal fun afterEach() {
|
||||||
clearAllMocks()
|
clearAllMocks()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun withParsedUserOutputDto(jwt: String, test: (UserDto) -> Unit) {
|
@Test
|
||||||
val serializedUser = jwtParser.parseClaimsJws(jwt)
|
fun buildUserJwt_normalBehavior_buildJwtWithValidSubject() {
|
||||||
.body.get(jwtClaimUser, String::class.java)
|
// Arrange
|
||||||
|
// Act
|
||||||
|
val jwt = jwtLogic.buildUserJwt(userDetails)
|
||||||
|
|
||||||
test(objectMapper.readValue(serializedUser))
|
// Assert
|
||||||
|
val parsedJwt = jwtLogic.parseUserJwt(jwt)
|
||||||
|
assertEquals(user.id.toString(), parsedJwt.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun buildJwt_userDetails_normalBehavior_returnsJwtStringWithValidUser() {
|
fun buildUserJwt_normalBehavior_buildJwtWithValidType() {
|
||||||
val userDetails = UserDetails(user)
|
// Arrange
|
||||||
|
// Act
|
||||||
|
val jwt = jwtLogic.buildUserJwt(userDetails)
|
||||||
|
|
||||||
val builtJwt = jwtService.buildJwt(userDetails)
|
// Assert
|
||||||
|
val parsedJwt = jwtLogic.parseUserJwt(jwt)
|
||||||
withParsedUserOutputDto(builtJwt) { parsedUser ->
|
assertFalse(parsedJwt.isGroup)
|
||||||
assertEquals(user, parsedUser)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun buildJwt_user_normalBehavior_returnsJwtStringWithValidUser() {
|
fun buildUserJwt_normalBehavior_buildJwtWithValidPermissions() {
|
||||||
val builtJwt = jwtService.buildJwt(user)
|
// Arrange
|
||||||
|
// Act
|
||||||
|
val jwt = jwtLogic.buildUserJwt(userDetails)
|
||||||
|
|
||||||
withParsedUserOutputDto(builtJwt) { parsedUser ->
|
// Assert
|
||||||
assertEquals(user, parsedUser)
|
val parsedJwt = jwtLogic.parseUserJwt(jwt)
|
||||||
}
|
assertEquals(userDetails.authorities, parsedJwt.authorities)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun buildJwt_user_normalBehavior_returnsJwtStringWithValidSubject() {
|
fun buildGroupTokenIdJwt_normalBehavior_buildJwtWithValidSubject(){
|
||||||
val builtJwt = jwtService.buildJwt(user)
|
// Arrange
|
||||||
val jwtSubject = jwtParser.parseClaimsJws(builtJwt).body.subject
|
// Act
|
||||||
|
val jwt = jwtLogic.buildGroupTokenIdJwt(groupTokenId)
|
||||||
|
|
||||||
assertEquals(user.id.toString(), jwtSubject)
|
// Assert
|
||||||
|
val parsedGroupId = jwtLogic.parseGroupTokenIdJwt(jwt)
|
||||||
|
assertEquals(groupTokenId, parsedGroupId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun buildJwt_user_returnsJwtWithValidExpirationDate() {
|
fun parseUserJwt_normalBehavior_returnsUserWithValidId() {
|
||||||
val jwtExpectedExpirationDate = Instant.now().plusSeconds(securityProperties.jwtDuration)
|
// Arrange
|
||||||
|
// Act
|
||||||
|
val user = jwtLogic.parseUserJwt(userJwt)
|
||||||
|
|
||||||
val builtJwt = jwtService.buildJwt(user)
|
// Assert
|
||||||
val jwtExpiration = jwtParser.parseClaimsJws(builtJwt)
|
assertEquals(userDetails.id, user.id)
|
||||||
.body.expiration.toInstant()
|
|
||||||
|
|
||||||
// Check if it's between 1 second
|
|
||||||
assertTrue { jwtExpiration.isAround(jwtExpectedExpirationDate) }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseJwt()
|
@Test
|
||||||
|
fun parseUserJwt_normalBehavior_returnsUserWithValidType() {
|
||||||
|
// Arrange
|
||||||
|
// Act
|
||||||
|
val user = jwtLogic.parseUserJwt(userJwt)
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertFalse(user.isGroup)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun parseJwt_normalBehavior_returnsExpectedUser() {
|
fun parseUserJwt_normalBehavior_returnsUserWithValidPermissions() {
|
||||||
val jwt = jwtService.buildJwt(user)
|
// Arrange
|
||||||
val parsedUser = jwtService.parseJwt(jwt)
|
// 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
package dev.fyloz.colorrecipesexplorer.logic
|
package dev.fyloz.colorrecipesexplorer.logic
|
||||||
|
|
||||||
import dev.fyloz.colorrecipesexplorer.dtos.*
|
import dev.fyloz.colorrecipesexplorer.dtos.*
|
||||||
|
import dev.fyloz.colorrecipesexplorer.dtos.account.GroupDto
|
||||||
import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException
|
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 dev.fyloz.colorrecipesexplorer.service.RecipeService
|
||||||
import io.mockk.*
|
import io.mockk.*
|
||||||
import org.junit.jupiter.api.AfterEach
|
import org.junit.jupiter.api.AfterEach
|
||||||
|
@ -213,4 +214,4 @@ class DefaultRecipeLogicTest {
|
||||||
mixLogicMock.updateLocations(any())
|
mixLogicMock.updateLocations(any())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
package dev.fyloz.colorrecipesexplorer.logic
|
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.RecipeGroupInformationDto
|
||||||
import dev.fyloz.colorrecipesexplorer.dtos.RecipeStepDto
|
import dev.fyloz.colorrecipesexplorer.dtos.RecipeStepDto
|
||||||
import dev.fyloz.colorrecipesexplorer.exception.InvalidPositionError
|
import dev.fyloz.colorrecipesexplorer.exception.InvalidPositionError
|
||||||
|
@ -57,4 +57,4 @@ class DefaultRecipeStepLogicTest {
|
||||||
// Assert
|
// Assert
|
||||||
assertThrows<InvalidGroupStepsPositionsException> { recipeStepLogic.validateGroupInformationSteps(groupInfo) }
|
assertThrows<InvalidGroupStepsPositionsException> { recipeStepLogic.validateGroupInformationSteps(groupInfo) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,9 @@
|
||||||
package dev.fyloz.colorrecipesexplorer.logic.account
|
package dev.fyloz.colorrecipesexplorer.logic.account
|
||||||
|
|
||||||
import dev.fyloz.colorrecipesexplorer.dtos.GroupDto
|
import dev.fyloz.colorrecipesexplorer.dtos.account.GroupDto
|
||||||
import dev.fyloz.colorrecipesexplorer.dtos.UserDto
|
import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto
|
||||||
import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException
|
import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException
|
||||||
import dev.fyloz.colorrecipesexplorer.logic.users.DefaultGroupLogic
|
import dev.fyloz.colorrecipesexplorer.service.account.GroupService
|
||||||
import dev.fyloz.colorrecipesexplorer.logic.users.UserLogic
|
|
||||||
import dev.fyloz.colorrecipesexplorer.service.GroupService
|
|
||||||
import io.mockk.*
|
import io.mockk.*
|
||||||
import org.junit.jupiter.api.AfterEach
|
import org.junit.jupiter.api.AfterEach
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
@ -25,9 +23,7 @@ class DefaultGroupLogicTest {
|
||||||
}
|
}
|
||||||
private val userLogicMock = mockk<UserLogic> {
|
private val userLogicMock = mockk<UserLogic> {
|
||||||
every { getAllByGroup(any()) } returns listOf()
|
every { getAllByGroup(any()) } returns listOf()
|
||||||
every { getById(any(), any(), any()) } returns user
|
every { getById(any(), any()) } returns user
|
||||||
every { getDefaultGroupUser(any()) } returns user
|
|
||||||
every { saveDefaultGroupUser(any()) } just runs
|
|
||||||
every { deleteById(any()) } just runs
|
every { deleteById(any()) } just runs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -72,16 +68,4 @@ class DefaultGroupLogicTest {
|
||||||
// Assert
|
// Assert
|
||||||
assertThrows<AlreadyExistsException> { groupLogic.update(group) }
|
assertThrows<AlreadyExistsException> { groupLogic.update(group) }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
@Test
|
|
||||||
fun deleteById_normalBehavior_callsDeleteByIdInUserLogicWithDefaultGroupUserId() {
|
|
||||||
// Arrange
|
|
||||||
// Act
|
|
||||||
groupLogic.deleteById(group.id)
|
|
||||||
|
|
||||||
// Assert
|
|
||||||
verify {
|
|
||||||
userLogicMock.deleteById(group.defaultGroupUserId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,15 +1,13 @@
|
||||||
package dev.fyloz.colorrecipesexplorer.logic.account
|
package dev.fyloz.colorrecipesexplorer.logic.account
|
||||||
|
|
||||||
import dev.fyloz.colorrecipesexplorer.dtos.GroupDto
|
import dev.fyloz.colorrecipesexplorer.dtos.account.GroupDto
|
||||||
import dev.fyloz.colorrecipesexplorer.dtos.UserDto
|
import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto
|
||||||
import dev.fyloz.colorrecipesexplorer.dtos.UserSaveDto
|
import dev.fyloz.colorrecipesexplorer.dtos.account.UserSaveDto
|
||||||
import dev.fyloz.colorrecipesexplorer.dtos.UserUpdateDto
|
import dev.fyloz.colorrecipesexplorer.dtos.account.UserUpdateDto
|
||||||
import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException
|
import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException
|
||||||
import dev.fyloz.colorrecipesexplorer.exception.NotFoundException
|
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.model.account.Permission
|
||||||
import dev.fyloz.colorrecipesexplorer.service.UserService
|
import dev.fyloz.colorrecipesexplorer.service.account.UserService
|
||||||
import io.mockk.*
|
import io.mockk.*
|
||||||
import org.junit.jupiter.api.AfterEach
|
import org.junit.jupiter.api.AfterEach
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
|
@ -24,11 +22,10 @@ class DefaultUserLogicTest {
|
||||||
private val userServiceMock = mockk<UserService> {
|
private val userServiceMock = mockk<UserService> {
|
||||||
every { existsById(any()) } returns false
|
every { existsById(any()) } returns false
|
||||||
every { existsByFirstNameAndLastName(any(), any(), 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 { getAllByGroup(any()) } returns listOf()
|
||||||
every { getById(any(), any(), any()) } returns user
|
every { getById(any(), any()) } returns user
|
||||||
every { getByFirstNameAndLastName(any(), any()) } returns user
|
every { getByFirstNameAndLastName(any(), any()) } returns user
|
||||||
every { getDefaultGroupUser(any()) } returns user
|
|
||||||
}
|
}
|
||||||
private val groupLogicMock = mockk<GroupLogic> {
|
private val groupLogicMock = mockk<GroupLogic> {
|
||||||
every { getById(any()) } returns group
|
every { getById(any()) } returns group
|
||||||
|
@ -46,8 +43,7 @@ class DefaultUserLogicTest {
|
||||||
user.password,
|
user.password,
|
||||||
null,
|
null,
|
||||||
user.permissions,
|
user.permissions,
|
||||||
user.isSystemUser,
|
user.isSystemUser
|
||||||
user.isDefaultGroupUser
|
|
||||||
)
|
)
|
||||||
private val userUpdateDto = UserUpdateDto(user.id, user.firstName, user.lastName, null, listOf())
|
private val userUpdateDto = UserUpdateDto(user.id, user.firstName, user.lastName, null, listOf())
|
||||||
|
|
||||||
|
@ -64,7 +60,7 @@ class DefaultUserLogicTest {
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
verify {
|
verify {
|
||||||
userServiceMock.getAll(isSystemUser = false, isDefaultGroupUser = false)
|
userServiceMock.getAll(isSystemUser = false)
|
||||||
}
|
}
|
||||||
confirmVerified(userServiceMock)
|
confirmVerified(userServiceMock)
|
||||||
}
|
}
|
||||||
|
@ -90,7 +86,7 @@ class DefaultUserLogicTest {
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
verify {
|
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() {
|
fun getById_normalBehavior_callsGetByIdInService() {
|
||||||
// Arrange
|
// Arrange
|
||||||
// Act
|
// Act
|
||||||
userLogic.getById(user.id, isSystemUser = false, isDefaultGroupUser = true)
|
userLogic.getById(user.id, isSystemUser = false)
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
verify {
|
verify {
|
||||||
userServiceMock.getById(user.id, isSystemUser = false, isDefaultGroupUser = true)
|
userServiceMock.getById(user.id, isSystemUser = false)
|
||||||
}
|
}
|
||||||
confirmVerified(userServiceMock)
|
confirmVerified(userServiceMock)
|
||||||
}
|
}
|
||||||
|
@ -110,54 +106,13 @@ class DefaultUserLogicTest {
|
||||||
@Test
|
@Test
|
||||||
fun getById_notFound_throwsNotFoundException() {
|
fun getById_notFound_throwsNotFoundException() {
|
||||||
// Arrange
|
// Arrange
|
||||||
every { userServiceMock.getById(any(), any(), any()) } returns null
|
every { userServiceMock.getById(any(), any()) } returns null
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
// Assert
|
// Assert
|
||||||
assertThrows<NotFoundException> { userLogic.getById(user.id) }
|
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
|
@Test
|
||||||
fun save_dto_normalBehavior_callsSaveWithValidUser() {
|
fun save_dto_normalBehavior_callsSaveWithValidUser() {
|
||||||
// Arrange
|
// Arrange
|
||||||
|
@ -210,7 +165,7 @@ class DefaultUserLogicTest {
|
||||||
@Test
|
@Test
|
||||||
fun update_dto_normalBehavior_callsUpdateWithValidUser() {
|
fun update_dto_normalBehavior_callsUpdateWithValidUser() {
|
||||||
// Arrange
|
// Arrange
|
||||||
every { userLogic.getById(any(), any(), any()) } returns user
|
every { userLogic.getById(any(), any()) } returns user
|
||||||
every { userLogic.update(any<UserDto>()) } returns user
|
every { userLogic.update(any<UserDto>()) } returns user
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
|
@ -303,4 +258,4 @@ class DefaultUserLogicTest {
|
||||||
userLogic.update(user)
|
userLogic.update(user)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue