diff --git a/build.gradle.kts b/build.gradle.kts index 1325653..c9e5674 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -106,10 +106,6 @@ tasks.withType() { tasks.withType().all { kotlinOptions { jvmTarget = JavaVersion.VERSION_17.toString() - freeCompilerArgs = listOf( - "-Xopt-in=kotlin.contracts.ExperimentalContracts", - "-Xinline-classes" - ) } } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/Constants.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/Constants.kt index d7853b7..66e863d 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/Constants.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/Constants.kt @@ -1,17 +1,33 @@ package dev.fyloz.colorrecipesexplorer object Constants { + val BEARER_PREFIX = "Bearer" + var DEBUG_MODE = false // Not really a constant, but should never change after the app startup + object ControllerPaths { - const val COMPANY = "/api/company" - const val FILE = "/api/file" - const val GROUP = "/api/user/group" - const val INVENTORY = "/api/inventory" - const val MATERIAL = "/api/material" - const val MATERIAL_TYPE = "/api/materialtype" - const val MIX = "/api/recipe/mix" - const val RECIPE = "/api/recipe" - const val TOUCH_UP_KIT = "/api/touchupkit" - const val USER = "/api/user" + const val BASE_PATH = "/api" + const val ACCOUNT_BASE_PATH = "$BASE_PATH/account" + + const val COMPANY = "$BASE_PATH/company" + const val GROUP_TOKEN = "$BASE_PATH/account/group/token" + const val FILE = "$BASE_PATH/file" + const val INVENTORY = "$BASE_PATH/inventory" + const val MATERIAL = "$BASE_PATH/material" + const val MATERIAL_TYPE = "$BASE_PATH/materialtype" + const val MIX = "$BASE_PATH/recipe/mix" + const val RECIPE = "$BASE_PATH/recipe" + const val TOUCH_UP_KIT = "$BASE_PATH/touchupkit" + + const val GROUP = "$ACCOUNT_BASE_PATH/group" + const val GROUP_LOGIN = "$ACCOUNT_BASE_PATH/login/group" + const val LOGIN = "$ACCOUNT_BASE_PATH/login" + const val LOGOUT = "$ACCOUNT_BASE_PATH/logout" + const val USER = "$ACCOUNT_BASE_PATH/user" + } + + object CookieNames { + const val AUTHORIZATION = "Authorization" + const val GROUP_TOKEN = "Group-Token" } object FilePaths { @@ -23,8 +39,14 @@ object Constants { const val RECIPE_IMAGES = "$IMAGES/recipes" } + object JwtType { + const val USER = 0 + const val GROUP = 1 + } + object ModelNames { const val COMPANY = "Company" + const val GROUP_TOKEN = "GroupToken" const val GROUP = "Group" const val MATERIAL = "Material" const val MATERIAL_TYPE = "MaterialType" @@ -47,4 +69,4 @@ object Constants { object ValidationRegexes { const val VALIDATION_COLOR_PATTERN = "^#([0-9a-f]{6})$" } -} \ No newline at end of file +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/annotations/PermissionAnnotations.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/annotations/PermissionAnnotations.kt index 8f78572..71fdd01 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/annotations/PermissionAnnotations.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/annotations/PermissionAnnotations.kt @@ -2,6 +2,12 @@ package dev.fyloz.colorrecipesexplorer.config.annotations import org.springframework.security.access.prepost.PreAuthorize +@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +@MustBeDocumented +@PreAuthorize("hasAuthority('ADMIN')") +annotation class PreAuthorizeAdmin + @Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS) @Retention(AnnotationRetention.RUNTIME) @MustBeDocumented diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupAuthenticationToken.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupAuthenticationToken.kt new file mode 100644 index 0000000..6368356 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupAuthenticationToken.kt @@ -0,0 +1,13 @@ +package dev.fyloz.colorrecipesexplorer.config.security + +import org.springframework.security.authentication.AbstractAuthenticationToken +import org.springframework.security.core.GrantedAuthority +import java.util.* + +class GroupAuthenticationToken(val id: 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 +} + diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupTokenAuthenticationProvider.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupTokenAuthenticationProvider.kt new file mode 100644 index 0000000..f0bf5fc --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupTokenAuthenticationProvider.kt @@ -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 + } +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/JwtFilters.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/JwtFilters.kt deleted file mode 100644 index 47f997f..0000000 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/JwtFilters.kt +++ /dev/null @@ -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() // 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()) -} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt index c17ee93..12661ad 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt @@ -1,11 +1,16 @@ package dev.fyloz.colorrecipesexplorer.config.security +import dev.fyloz.colorrecipesexplorer.Constants import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties -import dev.fyloz.colorrecipesexplorer.dtos.UserDto +import dev.fyloz.colorrecipesexplorer.config.security.filters.GroupTokenAuthenticationFilter +import dev.fyloz.colorrecipesexplorer.config.security.filters.JwtAuthorizationFilter +import dev.fyloz.colorrecipesexplorer.config.security.filters.UsernamePasswordAuthenticationFilter +import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto import dev.fyloz.colorrecipesexplorer.emergencyMode -import dev.fyloz.colorrecipesexplorer.logic.users.JwtLogic -import dev.fyloz.colorrecipesexplorer.logic.users.UserDetailsLogic -import dev.fyloz.colorrecipesexplorer.logic.users.UserLogic +import dev.fyloz.colorrecipesexplorer.logic.account.GroupTokenLogic +import dev.fyloz.colorrecipesexplorer.logic.account.JwtLogic +import dev.fyloz.colorrecipesexplorer.logic.account.UserDetailsLogic +import dev.fyloz.colorrecipesexplorer.logic.account.UserLogic import dev.fyloz.colorrecipesexplorer.model.account.Permission import mu.KotlinLogging import org.slf4j.Logger @@ -25,6 +30,7 @@ import org.springframework.security.config.http.SessionCreationPolicy import org.springframework.security.core.AuthenticationException import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder import org.springframework.security.web.AuthenticationEntryPoint +import org.springframework.security.web.authentication.www.BasicAuthenticationFilter import org.springframework.stereotype.Component import org.springframework.web.cors.CorsConfiguration import org.springframework.web.cors.UrlBasedCorsConfigurationSource @@ -39,13 +45,13 @@ private const val rootUserLastName = "User" abstract class BaseSecurityConfig( private val userDetailsLogic: UserDetailsLogic, private val jwtLogic: JwtLogic, + private val groupTokenLogic: GroupTokenLogic, private val environment: Environment, protected val securityProperties: CreSecurityProperties ) : WebSecurityConfigurerAdapter() { protected abstract val logger: Logger protected val passwordEncoder = BCryptPasswordEncoder() - var debugMode = false @Bean open fun passwordEncoder() = @@ -69,33 +75,43 @@ abstract class BaseSecurityConfig( } override fun configure(authBuilder: AuthenticationManagerBuilder) { - authBuilder.userDetailsService(userDetailsLogic).passwordEncoder(passwordEncoder) + authBuilder + .authenticationProvider(GroupTokenAuthenticationProvider(groupTokenLogic)) + .userDetailsService(userDetailsLogic).passwordEncoder(passwordEncoder) } override fun configure(http: HttpSecurity) { + val authManager = authenticationManager() + http .headers().frameOptions().disable() .and() .csrf().disable() - .addFilter( - JwtAuthenticationFilter( - authenticationManager(), + .addFilterBefore( + GroupTokenAuthenticationFilter(jwtLogic, securityProperties, groupTokenLogic, authManager), + BasicAuthenticationFilter::class.java + ) + .addFilterBefore( + UsernamePasswordAuthenticationFilter( jwtLogic, securityProperties, + authManager, this::updateUserLoginTime - ) + ), + BasicAuthenticationFilter::class.java ) .addFilter( - JwtAuthorizationFilter(jwtLogic, authenticationManager(), userDetailsLogic) + JwtAuthorizationFilter(jwtLogic, groupTokenLogic, authManager) ) .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers("/api/config/**").permitAll() // Allow access to logo and icon - .antMatchers("/api/login").permitAll() // Allow access to login + .antMatchers("/api/account/login").permitAll() // Allow access to login + .antMatchers("/api/account/login/group").permitAll() // Allow access to group login .antMatchers("**").fullyAuthenticated() - if (debugMode) { + if (Constants.DEBUG_MODE) { http .cors() } @@ -103,8 +119,10 @@ abstract class BaseSecurityConfig( @PostConstruct fun initDebugMode() { - debugMode = "debug" in environment.activeProfiles + val debugMode = "debug" in environment.activeProfiles if (debugMode) logger.warn("Debug mode is enabled, security will be decreased!") + + Constants.DEBUG_MODE = debugMode } protected open fun updateUserLoginTime(userId: Long) { @@ -120,9 +138,10 @@ class SecurityConfig( @Lazy userDetailsLogic: UserDetailsLogic, @Lazy private val userLogic: UserLogic, jwtLogic: JwtLogic, + groupTokenLogic: GroupTokenLogic, environment: Environment, securityProperties: CreSecurityProperties -) : BaseSecurityConfig(userDetailsLogic, jwtLogic, environment, securityProperties) { +) : BaseSecurityConfig(userDetailsLogic, jwtLogic, groupTokenLogic, environment, securityProperties) { override val logger = KotlinLogging.logger {} @PostConstruct @@ -168,9 +187,10 @@ class SecurityConfig( class EmergencySecurityConfig( userDetailsLogic: UserDetailsLogic, jwtLogic: JwtLogic, + groupTokenLogic: GroupTokenLogic, environment: Environment, securityProperties: CreSecurityProperties -) : BaseSecurityConfig(userDetailsLogic, jwtLogic, environment, securityProperties) { +) : BaseSecurityConfig(userDetailsLogic, jwtLogic, groupTokenLogic, environment, securityProperties) { override val logger = KotlinLogging.logger {} init { @@ -187,5 +207,5 @@ class RestAuthenticationEntryPoint : AuthenticationEntryPoint { ) = response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized") } -private class InvalidSystemUserException(userType: String, message: String) : +class InvalidSystemUserException(userType: String, message: String) : RuntimeException("Invalid $userType user: $message") diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/GroupTokenAuthenticationFilter.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/GroupTokenAuthenticationFilter.kt new file mode 100644 index 0000000..f114d8a --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/GroupTokenAuthenticationFilter.kt @@ -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})") + } +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthenticationFilter.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthenticationFilter.kt new file mode 100644 index 0000000..0653354 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthenticationFilter.kt @@ -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 + } +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthorizationFilter.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthorizationFilter.kt new file mode 100644 index 0000000..cb7e30c --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthorizationFilter.kt @@ -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) +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/UsernamePasswordAuthenticationFilter.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/UsernamePasswordAuthenticationFilter.kt new file mode 100644 index 0000000..cc44d14 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/UsernamePasswordAuthenticationFilter.kt @@ -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() + +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) +} \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/RecipeDto.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/RecipeDto.kt index 0a76be4..5a9d8d4 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/RecipeDto.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/RecipeDto.kt @@ -2,6 +2,7 @@ package dev.fyloz.colorrecipesexplorer.dtos import com.fasterxml.jackson.annotation.JsonIgnore import dev.fyloz.colorrecipesexplorer.Constants +import dev.fyloz.colorrecipesexplorer.dtos.account.GroupDto import java.time.LocalDate import javax.validation.constraints.Max import javax.validation.constraints.Min @@ -118,4 +119,4 @@ data class RecipePublicDataDto( val notes: List, val mixesLocation: List -) \ No newline at end of file +) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/UserDto.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/UserDto.kt deleted file mode 100644 index edfaef4..0000000 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/UserDto.kt +++ /dev/null @@ -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, - - val explicitPermissions: List = 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, - - // 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 -) - -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 -} \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/GroupDto.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/GroupDto.kt similarity index 65% rename from src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/GroupDto.kt rename to src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/GroupDto.kt index 78ab227..c756779 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/GroupDto.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/GroupDto.kt @@ -1,6 +1,7 @@ -package dev.fyloz.colorrecipesexplorer.dtos +package dev.fyloz.colorrecipesexplorer.dtos.account import com.fasterxml.jackson.annotation.JsonIgnore +import dev.fyloz.colorrecipesexplorer.dtos.EntityDto import dev.fyloz.colorrecipesexplorer.model.account.Permission import javax.validation.constraints.NotBlank import javax.validation.constraints.NotEmpty @@ -15,11 +16,4 @@ data class GroupDto( val permissions: List, val explicitPermissions: List = listOf() -) : EntityDto { - @get:JsonIgnore - val defaultGroupUserId = getDefaultGroupUserId(id) - - companion object { - fun getDefaultGroupUserId(id: Long) = 1000000 + id - } -} +) : EntityDto diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/GroupTokenDto.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/GroupTokenDto.kt new file mode 100644 index 0000000..f507e8c --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/GroupTokenDto.kt @@ -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 +) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/UserDto.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/UserDto.kt new file mode 100644 index 0000000..0c94b1a --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/UserDto.kt @@ -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, + + val explicitPermissions: List = 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, + + @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 +) + +data class UserLoginRequestDto(val id: Long, val password: String) + +data class UserJwt(val id: String, val authorities: Collection, val isGroup: Boolean) + +class UserDetails( + val id: String, + private val username: String, + private val password: String, + val group: GroupDto?, + val permissions: Collection, + val 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 +) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/RecipeLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/RecipeLogic.kt index 2687249..e444250 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/RecipeLogic.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/RecipeLogic.kt @@ -5,7 +5,7 @@ import dev.fyloz.colorrecipesexplorer.config.annotations.LogicComponent import dev.fyloz.colorrecipesexplorer.dtos.* import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException import dev.fyloz.colorrecipesexplorer.logic.files.WriteableFileLogic -import dev.fyloz.colorrecipesexplorer.logic.users.GroupLogic +import dev.fyloz.colorrecipesexplorer.logic.account.GroupLogic import dev.fyloz.colorrecipesexplorer.service.RecipeService import dev.fyloz.colorrecipesexplorer.utils.collections.LazyMapList import dev.fyloz.colorrecipesexplorer.utils.merge diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/RecipeStepLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/RecipeStepLogic.kt index 4aaf79f..eb24212 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/RecipeStepLogic.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/RecipeStepLogic.kt @@ -2,13 +2,12 @@ package dev.fyloz.colorrecipesexplorer.logic import dev.fyloz.colorrecipesexplorer.Constants import dev.fyloz.colorrecipesexplorer.config.annotations.LogicComponent -import dev.fyloz.colorrecipesexplorer.dtos.GroupDto +import dev.fyloz.colorrecipesexplorer.dtos.account.GroupDto import dev.fyloz.colorrecipesexplorer.dtos.RecipeGroupInformationDto import dev.fyloz.colorrecipesexplorer.dtos.RecipeStepDto import dev.fyloz.colorrecipesexplorer.exception.InvalidPositionError import dev.fyloz.colorrecipesexplorer.exception.InvalidPositionsException import dev.fyloz.colorrecipesexplorer.exception.RestException -import dev.fyloz.colorrecipesexplorer.model.account.Group import dev.fyloz.colorrecipesexplorer.service.RecipeStepService import dev.fyloz.colorrecipesexplorer.utils.PositionUtils import org.springframework.http.HttpStatus @@ -46,4 +45,4 @@ class InvalidGroupStepsPositionsException( ) { val errors: Set get() = exception.errors -} \ No newline at end of file +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupLogic.kt new file mode 100644 index 0000000..8780ddc --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupLogic.kt @@ -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 { + /** Gets all the users of the group with the given [id]. */ + fun getUsersForGroup(id: Long): Collection +} + +@LogicComponent +class DefaultGroupLogic(service: GroupService, private val userLogic: UserLogic) : + BaseLogic(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) + } + } +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupTokenLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupTokenLogic.kt new file mode 100644 index 0000000..b956a1a --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupTokenLogic.kt @@ -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 + 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 = 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 + ) +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/JwtLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/JwtLogic.kt new file mode 100644 index 0000000..1cda7b1 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/JwtLogic.kt @@ -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>(objectMapper)) + .signWith(secretKey) + + private val jwtParser by lazy { + Jwts.parserBuilder() + .deserializeJsonWith(JacksonDeserializer>(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>(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 + } +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/users/UserDetailsLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserDetailsLogic.kt similarity index 75% rename from src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/users/UserDetailsLogic.kt rename to src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserDetailsLogic.kt index 25b8369..b62a52c 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/users/UserDetailsLogic.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserDetailsLogic.kt @@ -1,11 +1,11 @@ -package dev.fyloz.colorrecipesexplorer.logic.users +package dev.fyloz.colorrecipesexplorer.logic.account import dev.fyloz.colorrecipesexplorer.SpringUserDetails import dev.fyloz.colorrecipesexplorer.SpringUserDetailsService import dev.fyloz.colorrecipesexplorer.config.annotations.RequireDatabase import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties -import dev.fyloz.colorrecipesexplorer.dtos.UserDetails -import dev.fyloz.colorrecipesexplorer.dtos.UserDto +import dev.fyloz.colorrecipesexplorer.dtos.account.UserDetails +import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto import dev.fyloz.colorrecipesexplorer.exception.NotFoundException import dev.fyloz.colorrecipesexplorer.model.account.Permission import dev.fyloz.colorrecipesexplorer.model.account.User @@ -15,7 +15,7 @@ import org.springframework.stereotype.Service interface UserDetailsLogic : SpringUserDetailsService { /** Loads an [User] for the given [id]. */ - fun loadUserById(id: Long, isDefaultGroupUser: Boolean = true): UserDetails + fun loadUserById(id: Long): UserDetails } @Service @@ -25,18 +25,14 @@ class DefaultUserDetailsLogic( ) : UserDetailsLogic { override fun loadUserByUsername(username: String): UserDetails { try { - return loadUserById(username.toLong(), false) + return loadUserById(username.toLong()) } catch (ex: NotFoundException) { throw UsernameNotFoundException(username) } } - override fun loadUserById(id: Long, isDefaultGroupUser: Boolean): UserDetails { - val user = userLogic.getById( - id, - isSystemUser = true, - isDefaultGroupUser = isDefaultGroupUser - ) + override fun loadUserById(id: Long): UserDetails { + val user = userLogic.getById(id, isSystemUser = true) return UserDetails(user) } } @@ -69,10 +65,10 @@ class EmergencyUserDetailsLogic( } override fun loadUserByUsername(username: String): SpringUserDetails { - return loadUserById(username.toLong(), false) + return loadUserById(username.toLong()) } - override fun loadUserById(id: Long, isDefaultGroupUser: Boolean): UserDetails { + override fun loadUserById(id: Long): UserDetails { val user = users.firstOrNull { it.id == id } ?: throw UsernameNotFoundException(id.toString()) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/users/UserLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserLogic.kt similarity index 69% rename from src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/users/UserLogic.kt rename to src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserLogic.kt index bd17f04..3916d89 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/users/UserLogic.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserLogic.kt @@ -1,20 +1,18 @@ -package dev.fyloz.colorrecipesexplorer.logic.users +package dev.fyloz.colorrecipesexplorer.logic.account import dev.fyloz.colorrecipesexplorer.Constants import dev.fyloz.colorrecipesexplorer.config.annotations.LogicComponent -import dev.fyloz.colorrecipesexplorer.config.security.authorizationCookieName -import dev.fyloz.colorrecipesexplorer.config.security.blacklistedJwtTokens -import dev.fyloz.colorrecipesexplorer.dtos.GroupDto -import dev.fyloz.colorrecipesexplorer.dtos.UserDto -import dev.fyloz.colorrecipesexplorer.dtos.UserSaveDto -import dev.fyloz.colorrecipesexplorer.dtos.UserUpdateDto +import dev.fyloz.colorrecipesexplorer.config.security.filters.blacklistedJwtTokens +import dev.fyloz.colorrecipesexplorer.dtos.account.GroupDto +import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto +import dev.fyloz.colorrecipesexplorer.dtos.account.UserSaveDto +import dev.fyloz.colorrecipesexplorer.dtos.account.UserUpdateDto import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException import dev.fyloz.colorrecipesexplorer.logic.BaseLogic import dev.fyloz.colorrecipesexplorer.logic.Logic import dev.fyloz.colorrecipesexplorer.model.account.Permission -import dev.fyloz.colorrecipesexplorer.service.UserService +import dev.fyloz.colorrecipesexplorer.service.account.UserService import org.springframework.context.annotation.Lazy -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder import org.springframework.security.crypto.password.PasswordEncoder import org.springframework.web.util.WebUtils import java.time.LocalDateTime @@ -25,13 +23,7 @@ interface UserLogic : Logic { fun getAllByGroup(group: GroupDto): Collection /** Gets the user with the given [id]. */ - fun getById(id: Long, isSystemUser: Boolean, isDefaultGroupUser: Boolean): UserDto - - /** Gets the default user of the given [group]. */ - fun getDefaultGroupUser(group: GroupDto): UserDto - - /** Save a default group user for the given [group]. */ - fun saveDefaultGroupUser(group: GroupDto) + fun getById(id: Long, isSystemUser: Boolean): UserDto /** Saves the given [dto]. */ fun save(dto: UserSaveDto): UserDto @@ -59,30 +51,13 @@ interface UserLogic : Logic { class DefaultUserLogic( service: UserService, @Lazy private val groupLogic: GroupLogic, @Lazy private val passwordEncoder: PasswordEncoder ) : BaseLogic(service, Constants.ModelNames.USER), UserLogic { - override fun getAll() = service.getAll(isSystemUser = false, isDefaultGroupUser = false) + override fun getAll() = service.getAll(false) override fun getAllByGroup(group: GroupDto) = service.getAllByGroup(group) - override fun getById(id: Long) = getById(id, isSystemUser = false, isDefaultGroupUser = false) - override fun getById(id: Long, isSystemUser: Boolean, isDefaultGroupUser: Boolean) = - service.getById(id, !isDefaultGroupUser, !isSystemUser) ?: throw notFoundException(value = id) - - override fun getDefaultGroupUser(group: GroupDto) = - service.getDefaultGroupUser(group) ?: throw notFoundException(identifierName = "groupId", value = group.id) - - override fun saveDefaultGroupUser(group: GroupDto) { - save( - UserSaveDto( - id = group.defaultGroupUserId, - firstName = group.name, - lastName = "User", - password = group.name, - groupId = group.id, - permissions = listOf(), - isDefaultGroupUser = true - ) - ) - } + override fun getById(id: Long) = getById(id, false) + override fun getById(id: Long, isSystemUser: Boolean) = + service.getById(id, isSystemUser) ?: throw notFoundException(value = id) override fun save(dto: UserSaveDto) = save( UserDto( @@ -92,8 +67,7 @@ class DefaultUserLogic( password = passwordEncoder.encode(dto.password), group = if (dto.groupId != null) groupLogic.getById(dto.groupId) else null, permissions = dto.permissions, - isSystemUser = dto.isSystemUser, - isDefaultGroupUser = dto.isDefaultGroupUser + isSystemUser = dto.isSystemUser ) ) @@ -105,7 +79,7 @@ class DefaultUserLogic( } override fun update(dto: UserUpdateDto): UserDto { - val user = getById(dto.id, isSystemUser = false, isDefaultGroupUser = false) + val user = getById(dto.id) return update( user.copy( @@ -123,7 +97,7 @@ class DefaultUserLogic( return super.update(dto) } - override fun updateLastLoginTime(id: Long, time: LocalDateTime) = with(getById(id)) { + override fun updateLastLoginTime(id: Long, time: LocalDateTime) = with(getById(id, true)) { update(this.copy(lastLoginTime = time)) } @@ -140,7 +114,7 @@ class DefaultUserLogic( } override fun logout(request: HttpServletRequest) { - val authorizationCookie = WebUtils.getCookie(request, authorizationCookieName) + val authorizationCookie = WebUtils.getCookie(request, Constants.CookieNames.AUTHORIZATION) if (authorizationCookie != null) { val authorizationToken = authorizationCookie.value if (authorizationToken != null && authorizationToken.startsWith("Bearer")) { @@ -166,4 +140,4 @@ class DefaultUserLogic( ) } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/users/GroupLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/users/GroupLogic.kt deleted file mode 100644 index b421223..0000000 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/users/GroupLogic.kt +++ /dev/null @@ -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 { - /** Gets all the users of the group with the given [id]. */ - fun getUsersForGroup(id: Long): Collection - - /** Gets the default group from a cookie in the given HTTP [request]. */ - fun getRequestDefaultGroup(request: HttpServletRequest): GroupDto - - /** Sets the default group cookie for the given HTTP [response]. */ - fun setResponseDefaultGroup(id: Long, response: HttpServletResponse) -} - -@LogicComponent -class DefaultGroupLogic(service: GroupService, private val userLogic: UserLogic) : - BaseLogic(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) - } - } -} \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/users/JwtLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/users/JwtLogic.kt deleted file mode 100644 index 47469bf..0000000 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/users/JwtLogic.kt +++ /dev/null @@ -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>(objectMapper)) - .signWith(secretKey) - } - - private val jwtParser by lazy { - Jwts.parserBuilder() - .deserializeJsonWith(JacksonDeserializer>(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) -} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/account/GroupToken.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/account/GroupToken.kt new file mode 100644 index 0000000..7111906 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/account/GroupToken.kt @@ -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 +) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/account/Permission.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/account/Permission.kt index abd2fb0..ecc0928 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/account/Permission.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/account/Permission.kt @@ -4,32 +4,34 @@ import org.springframework.security.core.GrantedAuthority import org.springframework.security.core.authority.SimpleGrantedAuthority enum class Permission( - val impliedPermissions: List = listOf(), + val id: Int, + private val impliedPermissions: List = listOf(), val deprecated: Boolean = false ) { - READ_FILE, - WRITE_FILE(listOf(READ_FILE)), + READ_FILE(0), + WRITE_FILE(1, listOf(READ_FILE)), - VIEW_RECIPES(listOf(READ_FILE)), - VIEW_CATALOG(listOf(READ_FILE)), - VIEW_USERS, + VIEW_RECIPES(2, listOf(READ_FILE)), + VIEW_CATALOG(3, listOf(READ_FILE)), + VIEW_USERS(4), - EDIT_RECIPES_PUBLIC_DATA(listOf(VIEW_RECIPES)), - EDIT_RECIPES(listOf(EDIT_RECIPES_PUBLIC_DATA, WRITE_FILE)), - EDIT_MATERIALS(listOf(VIEW_CATALOG, WRITE_FILE)), - EDIT_MATERIAL_TYPES(listOf(VIEW_CATALOG)), - EDIT_COMPANIES(listOf(VIEW_CATALOG)), - EDIT_USERS(listOf(VIEW_USERS)), - EDIT_CATALOG(listOf(EDIT_MATERIALS, EDIT_MATERIAL_TYPES, EDIT_COMPANIES)), + EDIT_RECIPES_PUBLIC_DATA(5, listOf(VIEW_RECIPES)), + EDIT_RECIPES(6, listOf(EDIT_RECIPES_PUBLIC_DATA, WRITE_FILE)), + EDIT_MATERIALS(7, listOf(VIEW_CATALOG, WRITE_FILE)), + EDIT_MATERIAL_TYPES(8, listOf(VIEW_CATALOG)), + EDIT_COMPANIES(9, listOf(VIEW_CATALOG)), + EDIT_USERS(10, listOf(VIEW_USERS)), + EDIT_CATALOG(11, listOf(EDIT_MATERIALS, EDIT_MATERIAL_TYPES, EDIT_COMPANIES)), - VIEW_TOUCH_UP_KITS, - EDIT_TOUCH_UP_KITS(listOf(VIEW_TOUCH_UP_KITS)), + VIEW_TOUCH_UP_KITS(12), + EDIT_TOUCH_UP_KITS(13, listOf(VIEW_TOUCH_UP_KITS)), - PRINT_MIXES(listOf(VIEW_RECIPES)), - ADD_TO_INVENTORY(listOf(VIEW_CATALOG)), - DEDUCT_FROM_INVENTORY(listOf(VIEW_RECIPES)), + PRINT_MIXES(14, listOf(VIEW_RECIPES)), + ADD_TO_INVENTORY(15, listOf(VIEW_CATALOG)), + DEDUCT_FROM_INVENTORY(16, listOf(VIEW_RECIPES)), ADMIN( + 17, listOf( EDIT_RECIPES, EDIT_CATALOG, @@ -44,58 +46,58 @@ enum class Permission( ), // deprecated permissions - VIEW_RECIPE(listOf(VIEW_RECIPES), true), - VIEW_MATERIAL(listOf(VIEW_CATALOG), true), - VIEW_MATERIAL_TYPE(listOf(VIEW_CATALOG), true), - VIEW_COMPANY(listOf(VIEW_CATALOG), true), - VIEW(listOf(VIEW_RECIPES, VIEW_CATALOG), true), - VIEW_EMPLOYEE(listOf(VIEW_USERS), true), - VIEW_EMPLOYEE_GROUP(listOf(VIEW_USERS), true), + VIEW_RECIPE(101, listOf(VIEW_RECIPES), true), + VIEW_MATERIAL(102, listOf(VIEW_CATALOG), true), + VIEW_MATERIAL_TYPE(103, listOf(VIEW_CATALOG), true), + VIEW_COMPANY(104, listOf(VIEW_CATALOG), true), + VIEW(105, listOf(VIEW_RECIPES, VIEW_CATALOG), true), + VIEW_EMPLOYEE(106, listOf(VIEW_USERS), true), + VIEW_EMPLOYEE_GROUP(107, listOf(VIEW_USERS), true), - EDIT_RECIPE(listOf(EDIT_RECIPES), true), - EDIT_MATERIAL(listOf(EDIT_MATERIALS), true), - EDIT_MATERIAL_TYPE(listOf(EDIT_MATERIAL_TYPES), true), - EDIT_COMPANY(listOf(EDIT_COMPANIES), true), - EDIT(listOf(EDIT_RECIPES, EDIT_CATALOG), true), - EDIT_EMPLOYEE(listOf(EDIT_USERS), true), - EDIT_EMPLOYEE_PASSWORD(listOf(EDIT_USERS), true), - EDIT_EMPLOYEE_GROUP(listOf(EDIT_USERS), true), + EDIT_RECIPE(108, listOf(EDIT_RECIPES), true), + EDIT_MATERIAL(109, listOf(EDIT_MATERIALS), true), + EDIT_MATERIAL_TYPE(110, listOf(EDIT_MATERIAL_TYPES), true), + EDIT_COMPANY(111, listOf(EDIT_COMPANIES), true), + EDIT(112, listOf(EDIT_RECIPES, EDIT_CATALOG), true), + EDIT_EMPLOYEE(113, listOf(EDIT_USERS), true), + EDIT_EMPLOYEE_PASSWORD(114, listOf(EDIT_USERS), true), + EDIT_EMPLOYEE_GROUP(115, listOf(EDIT_USERS), true), - REMOVE_FILE(listOf(WRITE_FILE), true), - GENERATE_TOUCH_UP_KIT(listOf(VIEW_TOUCH_UP_KITS), true), + REMOVE_FILE(116, listOf(WRITE_FILE), true), + GENERATE_TOUCH_UP_KIT(117, listOf(VIEW_TOUCH_UP_KITS), true), - REMOVE_RECIPES(listOf(EDIT_RECIPES, REMOVE_FILE), true), - REMOVE_MATERIALS(listOf(EDIT_MATERIALS, REMOVE_FILE), true), - REMOVE_MATERIAL_TYPES(listOf(EDIT_MATERIAL_TYPES), true), - REMOVE_COMPANIES(listOf(EDIT_COMPANIES), true), - REMOVE_USERS(listOf(EDIT_USERS), true), - REMOVE_CATALOG(listOf(REMOVE_MATERIALS, REMOVE_MATERIAL_TYPES, REMOVE_COMPANIES), true), + REMOVE_RECIPES(118, listOf(EDIT_RECIPES, REMOVE_FILE), true), + REMOVE_MATERIALS(119, listOf(EDIT_MATERIALS, REMOVE_FILE), true), + REMOVE_MATERIAL_TYPES(120, listOf(EDIT_MATERIAL_TYPES), true), + REMOVE_COMPANIES(121, listOf(EDIT_COMPANIES), true), + REMOVE_USERS(122, listOf(EDIT_USERS), true), + REMOVE_CATALOG(123, listOf(REMOVE_MATERIALS, REMOVE_MATERIAL_TYPES, REMOVE_COMPANIES), true), - REMOVE_RECIPE(listOf(REMOVE_RECIPES), true), - REMOVE_MATERIAL(listOf(REMOVE_MATERIALS), true), - REMOVE_MATERIAL_TYPE(listOf(REMOVE_MATERIAL_TYPES), true), - REMOVE_COMPANY(listOf(REMOVE_COMPANIES), true), - REMOVE(listOf(REMOVE_RECIPES, REMOVE_CATALOG), true), - REMOVE_EMPLOYEE(listOf(REMOVE_USERS), true), - REMOVE_EMPLOYEE_GROUP(listOf(REMOVE_USERS), true), + REMOVE_RECIPE(124, listOf(REMOVE_RECIPES), true), + REMOVE_MATERIAL(125, listOf(REMOVE_MATERIALS), true), + REMOVE_MATERIAL_TYPE(126, listOf(REMOVE_MATERIAL_TYPES), true), + REMOVE_COMPANY(127, listOf(REMOVE_COMPANIES), true), + REMOVE(128, listOf(REMOVE_RECIPES, REMOVE_CATALOG), true), + REMOVE_EMPLOYEE(129, listOf(REMOVE_USERS), true), + REMOVE_EMPLOYEE_GROUP(130, listOf(REMOVE_USERS), true), - SET_BROWSER_DEFAULT_GROUP(listOf(VIEW_USERS), true), + SET_BROWSER_DEFAULT_GROUP(131, listOf(VIEW_USERS), true), ; operator fun contains(permission: Permission): Boolean { return permission == this || impliedPermissions.any { permission in it } } -} -fun Permission.flat(): Iterable { - return mutableSetOf(this).apply { - impliedPermissions.forEach { - addAll(it.flat()) + fun flat(): Iterable { + return mutableSetOf(this).apply { + impliedPermissions.forEach { + addAll(it.flat()) + } } } -} -/** Converts the given [Permission] to a [GrantedAuthority]. */ -fun Permission.toAuthority(): GrantedAuthority { - return SimpleGrantedAuthority(name) + /** Converts the given permission to a [GrantedAuthority]. */ + fun toAuthority(): GrantedAuthority { + return SimpleGrantedAuthority(name) + } } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/AccountRepository.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/AccountRepository.kt index 82575bd..dee225e 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/AccountRepository.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/AccountRepository.kt @@ -1,26 +1,34 @@ package dev.fyloz.colorrecipesexplorer.repository import dev.fyloz.colorrecipesexplorer.model.account.Group +import dev.fyloz.colorrecipesexplorer.model.account.GroupToken import dev.fyloz.colorrecipesexplorer.model.account.User import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query import org.springframework.stereotype.Repository +import java.util.* +/** + * Default group users are deprecated and should not be used anymore. + * To prevent data loss, they will not be removed from the database, + * but they are excluded from results from the database. + */ @Repository interface UserRepository : JpaRepository { + fun findAllByIsDefaultGroupUserIsFalse(): MutableList + + fun findByIdAndIsDefaultGroupUserIsFalse(id: Long): User? + /** Checks if a user with the given [firstName], [lastName] and a different [id] exists. */ - fun existsByFirstNameAndLastNameAndIdNot(firstName: String, lastName: String, id: Long): Boolean + fun existsByFirstNameAndLastNameAndIdNotAndIsDefaultGroupUserIsFalse(firstName: String, lastName: String, id: Long): Boolean /** Finds all users for the given [group]. */ @Query("SELECT u FROM User u WHERE u.group = :group AND u.isSystemUser IS FALSE AND u.isDefaultGroupUser IS FALSE") fun findAllByGroup(group: Group): Collection /** Finds the user with the given [firstName] and [lastName]. */ + @Query("SELECT u From User u WHERE u.firstName = :firstName AND u.lastName = :lastName") fun findByFirstNameAndLastName(firstName: String, lastName: String): User? - - /** Finds the default user for the given [group]. */ - @Query("SELECT u FROM User u WHERE u.group = :group AND u.isDefaultGroupUser IS TRUE") - fun findDefaultGroupUser(group: Group): User? } @Repository @@ -28,3 +36,15 @@ interface GroupRepository : JpaRepository { /** Checks if a group with the given [name] and a different [id] exists. */ fun existsByNameAndIdNot(name: String, id: Long): Boolean } + +@Repository +interface GroupTokenRepository : JpaRepository { + /** Checks if a token that is not deleted with the given [name] exists. */ + fun existsByNameAndIsDeletedIsFalse(name: String): Boolean + + /** Finds all group tokens that are not deleted. */ + fun findAllByIsDeletedIsFalse(): Collection + + /** Finds the group token with the given [id] if it is not deleted. */ + fun findByIdAndIsDeletedIsFalse(id: UUID): GroupToken? +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/ConfigurationController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/ConfigurationController.kt index d66b67b..c9f1e80 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/ConfigurationController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/ConfigurationController.kt @@ -4,7 +4,6 @@ import dev.fyloz.colorrecipesexplorer.logic.config.ConfigurationLogic import dev.fyloz.colorrecipesexplorer.model.ConfigurationBase import dev.fyloz.colorrecipesexplorer.model.ConfigurationDto import dev.fyloz.colorrecipesexplorer.model.account.Permission -import dev.fyloz.colorrecipesexplorer.model.account.toAuthority import dev.fyloz.colorrecipesexplorer.restartApplication import org.springframework.http.MediaType import org.springframework.security.access.prepost.PreAuthorize diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupController.kt new file mode 100644 index 0000000..ce394b2 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupController.kt @@ -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(Constants.ControllerPaths.GROUP) { + groupLogic.save(group) + } + + @PutMapping + @PreAuthorizeEditUsers + fun update(@Valid @RequestBody group: GroupDto) = + noContent { + groupLogic.update(group) + } + + @DeleteMapping("{id}") + @PreAuthorizeEditUsers + fun deleteById(@PathVariable id: Long) = + noContent { + groupLogic.deleteById(id) + } +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupTokenController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupTokenController.kt new file mode 100644 index 0000000..828192a --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupTokenController.kt @@ -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 { + 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 + } +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/AccountControllers.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/UserController.kt similarity index 52% rename from src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/AccountControllers.kt rename to src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/UserController.kt index dd423b4..13414d2 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/AccountControllers.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/UserController.kt @@ -1,21 +1,21 @@ -package dev.fyloz.colorrecipesexplorer.rest +package dev.fyloz.colorrecipesexplorer.rest.account import dev.fyloz.colorrecipesexplorer.Constants import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeEditUsers import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeViewUsers -import dev.fyloz.colorrecipesexplorer.dtos.GroupDto -import dev.fyloz.colorrecipesexplorer.dtos.UserDto -import dev.fyloz.colorrecipesexplorer.dtos.UserSaveDto -import dev.fyloz.colorrecipesexplorer.dtos.UserUpdateDto -import dev.fyloz.colorrecipesexplorer.logic.users.GroupLogic -import dev.fyloz.colorrecipesexplorer.logic.users.UserLogic +import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto +import dev.fyloz.colorrecipesexplorer.dtos.account.UserSaveDto +import dev.fyloz.colorrecipesexplorer.dtos.account.UserUpdateDto +import dev.fyloz.colorrecipesexplorer.logic.account.UserLogic import dev.fyloz.colorrecipesexplorer.model.account.Permission +import dev.fyloz.colorrecipesexplorer.rest.created +import dev.fyloz.colorrecipesexplorer.rest.noContent +import dev.fyloz.colorrecipesexplorer.rest.ok import org.springframework.context.annotation.Profile import org.springframework.http.MediaType import org.springframework.security.access.prepost.PreAuthorize import org.springframework.web.bind.annotation.* import javax.servlet.http.HttpServletRequest -import javax.servlet.http.HttpServletResponse import javax.validation.Valid @RestController @@ -78,74 +78,10 @@ class UserController(private val userLogic: UserLogic) { } @RestController -@RequestMapping(Constants.ControllerPaths.GROUP) -@Profile("!emergency") -class GroupsController( - private val groupLogic: GroupLogic, - private val userLogic: UserLogic -) { - @GetMapping - @PreAuthorize("hasAnyAuthority('VIEW_RECIPES', 'VIEW_USERS')") - fun getAll() = - ok(groupLogic.getAll()) - - @GetMapping("{id}") - @PreAuthorizeViewUsers - fun getById(@PathVariable id: Long) = - ok(groupLogic.getById(id)) - - @GetMapping("{id}/users") - @PreAuthorizeViewUsers - fun getUsersForGroup(@PathVariable id: Long) = - ok(groupLogic.getUsersForGroup(id)) - - @PostMapping("default/{groupId}") - @PreAuthorizeViewUsers - fun setDefaultGroup(@PathVariable groupId: Long, response: HttpServletResponse) = - noContent { - groupLogic.setResponseDefaultGroup(groupId, response) - } - - @GetMapping("default") - @PreAuthorizeViewUsers - fun getRequestDefaultGroup(request: HttpServletRequest) = - ok(with(groupLogic) { - getRequestDefaultGroup(request) - }) - - @GetMapping("currentuser") - fun getCurrentGroupUser(request: HttpServletRequest) = - ok(with(groupLogic.getRequestDefaultGroup(request)) { - userLogic.getDefaultGroupUser(this) - }) - - @PostMapping - @PreAuthorizeEditUsers - fun save(@Valid @RequestBody group: GroupDto) = - created(Constants.ControllerPaths.GROUP) { - groupLogic.save(group) - } - - @PutMapping - @PreAuthorizeEditUsers - fun update(@Valid @RequestBody group: GroupDto) = - noContent { - groupLogic.update(group) - } - - @DeleteMapping("{id}") - @PreAuthorizeEditUsers - fun deleteById(@PathVariable id: Long) = - noContent { - groupLogic.deleteById(id) - } -} - -@RestController -@RequestMapping("api") +@RequestMapping(Constants.ControllerPaths.LOGOUT) @Profile("!emergency") class LogoutController(private val userLogic: UserLogic) { - @GetMapping("logout") + @GetMapping @PreAuthorize("isFullyAuthenticated()") fun logout(request: HttpServletRequest) = ok { diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeService.kt index 8532a02..e0edbf0 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeService.kt @@ -8,6 +8,7 @@ import dev.fyloz.colorrecipesexplorer.model.ConfigurationType import dev.fyloz.colorrecipesexplorer.model.Recipe import dev.fyloz.colorrecipesexplorer.model.RecipeGroupInformation import dev.fyloz.colorrecipesexplorer.repository.RecipeRepository +import dev.fyloz.colorrecipesexplorer.service.account.GroupService import dev.fyloz.colorrecipesexplorer.utils.collections.lazyMap import org.springframework.transaction.annotation.Transactional import java.time.LocalDate @@ -89,4 +90,4 @@ class DefaultRecipeService( with(Period.parse(configLogic.getContent(ConfigurationType.RECIPE_APPROBATION_EXPIRATION))) { recipe.approbationDate?.plus(this)?.isBefore(LocalDate.now()) } -} \ No newline at end of file +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/GroupService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/account/GroupService.kt similarity index 84% rename from src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/GroupService.kt rename to src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/account/GroupService.kt index 1b13ced..773deb7 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/GroupService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/account/GroupService.kt @@ -1,11 +1,12 @@ -package dev.fyloz.colorrecipesexplorer.service +package dev.fyloz.colorrecipesexplorer.service.account import dev.fyloz.colorrecipesexplorer.config.annotations.ServiceComponent -import dev.fyloz.colorrecipesexplorer.dtos.GroupDto +import dev.fyloz.colorrecipesexplorer.dtos.account.GroupDto import dev.fyloz.colorrecipesexplorer.model.account.Group import dev.fyloz.colorrecipesexplorer.model.account.Permission -import dev.fyloz.colorrecipesexplorer.model.account.flat import dev.fyloz.colorrecipesexplorer.repository.GroupRepository +import dev.fyloz.colorrecipesexplorer.service.BaseService +import dev.fyloz.colorrecipesexplorer.service.Service interface GroupService : Service { /** Checks if a group with the given [name] and a different [id] exists. */ @@ -28,4 +29,4 @@ class DefaultGroupService(repository: GroupRepository) : BaseService + fun getById(id: UUID): GroupTokenDto? + fun save(token: GroupTokenDto): GroupTokenDto + fun deleteById(id: UUID) + + fun toDto(entity: GroupToken): GroupTokenDto + fun toEntity(dto: GroupTokenDto): GroupToken +} + +@ServiceComponent +class DefaultGroupTokenService(private val repository: GroupTokenRepository, private val groupService: GroupService) : + GroupTokenService { + override fun existsById(id: UUID) = repository.existsById(id) + override fun existsByName(name: String) = repository.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)) +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/UserService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/account/UserService.kt similarity index 62% rename from src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/UserService.kt rename to src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/account/UserService.kt index 4792397..299128d 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/UserService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/account/UserService.kt @@ -1,55 +1,49 @@ -package dev.fyloz.colorrecipesexplorer.service +package dev.fyloz.colorrecipesexplorer.service.account import dev.fyloz.colorrecipesexplorer.config.annotations.ServiceComponent -import dev.fyloz.colorrecipesexplorer.dtos.GroupDto -import dev.fyloz.colorrecipesexplorer.dtos.UserDto +import dev.fyloz.colorrecipesexplorer.dtos.account.GroupDto +import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto import dev.fyloz.colorrecipesexplorer.model.account.Permission import dev.fyloz.colorrecipesexplorer.model.account.User -import dev.fyloz.colorrecipesexplorer.model.account.flat import dev.fyloz.colorrecipesexplorer.repository.UserRepository -import org.springframework.data.repository.findByIdOrNull +import dev.fyloz.colorrecipesexplorer.service.BaseService +import dev.fyloz.colorrecipesexplorer.service.Service interface UserService : Service { /** Checks if a user with the given [firstName] and [lastName] exists. */ fun existsByFirstNameAndLastName(firstName: String, lastName: String, id: Long? = null): Boolean - /** Gets all users, depending on [isSystemUser] and [isDefaultGroupUser]. */ - fun getAll(isSystemUser: Boolean, isDefaultGroupUser: Boolean): Collection + /** Gets all users, depending on [isSystemUser]. */ + fun getAll(isSystemUser: Boolean): Collection /** Gets all users for the given [group]. */ fun getAllByGroup(group: GroupDto): Collection - /** Finds the user with the given [id], depending on [isSystemUser] and [isDefaultGroupUser]. */ - fun getById(id: Long, isSystemUser: Boolean, isDefaultGroupUser: Boolean): UserDto? + /** Finds the user with the given [id], depending on [isSystemUser]. */ + fun getById(id: Long, isSystemUser: Boolean): UserDto? /** Finds the user with the given [firstName] and [lastName]. */ fun getByFirstNameAndLastName(firstName: String, lastName: String): UserDto? - - /** Find the default user for the given [group]. */ - fun getDefaultGroupUser(group: GroupDto): UserDto? } @ServiceComponent class DefaultUserService(repository: UserRepository, private val groupService: GroupService) : BaseService(repository), UserService { override fun existsByFirstNameAndLastName(firstName: String, lastName: String, id: Long?) = - repository.existsByFirstNameAndLastNameAndIdNot(firstName, lastName, id ?: 0L) + repository.existsByFirstNameAndLastNameAndIdNotAndIsDefaultGroupUserIsFalse(firstName, lastName, id ?: 0L) - override fun getAll(isSystemUser: Boolean, isDefaultGroupUser: Boolean) = - repository.findAll() + override fun getAll(isSystemUser: Boolean) = + repository.findAllByIsDefaultGroupUserIsFalse() .filter { isSystemUser || !it.isSystemUser } - .filter { isDefaultGroupUser || !it.isDefaultGroupUser } .map(::toDto) override fun getAllByGroup(group: GroupDto) = repository.findAllByGroup(groupService.toEntity(group)) .map(::toDto) - override fun getById(id: Long, isSystemUser: Boolean, isDefaultGroupUser: Boolean): UserDto? { - val user = repository.findByIdOrNull(id) ?: return null - if ((!isSystemUser && user.isSystemUser) || - !isDefaultGroupUser && user.isDefaultGroupUser - ) { + override fun getById(id: Long, isSystemUser: Boolean): UserDto? { + val user = repository.findByIdAndIsDefaultGroupUserIsFalse(id) ?: return null + if (!isSystemUser && user.isSystemUser) { return null } @@ -61,11 +55,6 @@ class DefaultUserService(repository: UserRepository, private val groupService: G return if (user != null) toDto(user) else null } - override fun getDefaultGroupUser(group: GroupDto): UserDto? { - val user = repository.findDefaultGroupUser(groupService.toEntity(group)) - return if (user != null) toDto(user) else null - } - override fun toDto(entity: User) = UserDto( entity.id, entity.firstName, @@ -75,7 +64,6 @@ class DefaultUserService(repository: UserRepository, private val groupService: G getFlattenPermissions(entity), entity.permissions, entity.lastLoginTime, - entity.isDefaultGroupUser, entity.isSystemUser ) @@ -84,7 +72,7 @@ class DefaultUserService(repository: UserRepository, private val groupService: G dto.firstName, dto.lastName, dto.password, - dto.isDefaultGroupUser, + false, dto.isSystemUser, if (dto.group != null) groupService.toEntity(dto.group) else null, dto.explicitPermissions, @@ -98,6 +86,6 @@ class DefaultUserService(repository: UserRepository, private val groupService: G return perms + groupService.flattenPermissions(user.group) } - return perms + return perms.distinctBy { it.id } } -} \ No newline at end of file +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Http.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Http.kt index b9af339..c83f0cb 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Http.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Http.kt @@ -1,31 +1,40 @@ package dev.fyloz.colorrecipesexplorer.utils +import dev.fyloz.colorrecipesexplorer.Constants import javax.servlet.http.HttpServletResponse -private const val defaultCookieMaxAge = 3600L -private const val defaultCookieHttpOnly = true -private const val defaultCookieSameSite = true -private const val defaultCookieSecure = true data class CookieBuilderOptions( /** HTTP Only cookies cannot be access by Javascript clients. */ - var httpOnly: Boolean = defaultCookieHttpOnly, + var httpOnly: Boolean = DEFAULT_HTTP_ONLY, /** SameSite cookies are only sent in requests to their origin location. */ - var sameSite: Boolean = defaultCookieSameSite, + var sameSite: Boolean = DEFAULT_SAME_SITE, /** Secure cookies are only sent in HTTPS requests. */ - var secure: Boolean = defaultCookieSecure, + var secure: Boolean = DEFAULT_SECURE, /** Cookie's maximum age in seconds. */ - var maxAge: Long = defaultCookieMaxAge -) + var maxAge: Long? = DEFAULT_MAX_AGE, + + /** The path for which the cookie will be sent. */ + var path: String = DEFAULT_PATH +) { + companion object { + private const val DEFAULT_MAX_AGE = 3600L + private const val DEFAULT_HTTP_ONLY = true + private const val DEFAULT_SAME_SITE = true + private const val DEFAULT_SECURE = true + private const val DEFAULT_PATH = Constants.ControllerPaths.BASE_PATH + } +} private enum class CookieBuilderOption(val optionName: String) { HTTP_ONLY("HttpOnly"), SAME_SITE("SameSite"), SECURE("Secure"), - MAX_AGE("Max-Age") + MAX_AGE("Max-Age"), + PATH("Path") } fun HttpServletResponse.addCookie(name: String, value: String, optionsBuilder: CookieBuilderOptions.() -> Unit) { @@ -42,7 +51,8 @@ private fun buildCookie(name: String, value: String, optionsBuilder: CookieBuild } } - fun addOption(option: CookieBuilderOption, value: Any) { + fun addOption(option: CookieBuilderOption, value: Any?) { + if (value == null) return cookie.append("${option.optionName}=$value;") } @@ -50,6 +60,10 @@ private fun buildCookie(name: String, value: String, optionsBuilder: CookieBuild addBoolOption(CookieBuilderOption.SAME_SITE, options.sameSite) addBoolOption(CookieBuilderOption.SECURE, options.secure) addOption(CookieBuilderOption.MAX_AGE, options.maxAge) + addOption(CookieBuilderOption.PATH, options.path) return cookie.toString() } + +fun parseBearer(source: String) = + source.replace(Constants.BEARER_PREFIX, "").trim() \ No newline at end of file diff --git a/src/main/resources/application-debug.properties b/src/main/resources/application-debug.properties index 72fc330..6555358 100644 --- a/src/main/resources/application-debug.properties +++ b/src/main/resources/application-debug.properties @@ -1 +1 @@ -spring.jpa.show-sql=true \ No newline at end of file +spring.jpa.show-sql=false diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultJwtLogicTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultJwtLogicTest.kt index e5ddab7..37e98ff 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultJwtLogicTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultJwtLogicTest.kt @@ -1,100 +1,122 @@ package dev.fyloz.colorrecipesexplorer.logic import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import com.fasterxml.jackson.module.kotlin.readValue import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties -import dev.fyloz.colorrecipesexplorer.dtos.UserDetails -import dev.fyloz.colorrecipesexplorer.dtos.UserDto -import dev.fyloz.colorrecipesexplorer.logic.users.DefaultJwtLogic -import dev.fyloz.colorrecipesexplorer.logic.users.jwtClaimUser -import dev.fyloz.colorrecipesexplorer.utils.base64encode -import dev.fyloz.colorrecipesexplorer.utils.isAround -import io.jsonwebtoken.Jwts -import io.jsonwebtoken.jackson.io.JacksonDeserializer +import dev.fyloz.colorrecipesexplorer.dtos.account.UserDetails +import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto +import dev.fyloz.colorrecipesexplorer.logic.account.DefaultJwtLogic +import dev.fyloz.colorrecipesexplorer.model.account.Permission import io.mockk.clearAllMocks import io.mockk.spyk import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test -import java.time.Instant +import java.util.* import kotlin.test.assertEquals -import kotlin.test.assertTrue +import kotlin.test.assertFalse class DefaultJwtLogicTest { private val objectMapper = jacksonObjectMapper() private val securityProperties = CreSecurityProperties().apply { - jwtSecret = "XRRm7OflmFuCrOB2Xvmfsercih9DCKom" + jwtSecret = "exBwMbD9Jw7YF7HYpwXQjcsPf4SrRSSF5YTvgbj0" jwtDuration = 1000000L } - private val jwtParser by lazy { - Jwts.parserBuilder() - .deserializeJsonWith(JacksonDeserializer>(objectMapper)) - .setSigningKey(securityProperties.jwtSecret.base64encode()) - .build() - } - private val jwtService = spyk(DefaultJwtLogic(objectMapper, securityProperties)) + private val jwtLogic = spyk(DefaultJwtLogic(objectMapper, securityProperties)) - private val user = UserDto(0L, "Unit test", "User", "", null, listOf()) + private val permissions = listOf(Permission.VIEW_RECIPES, Permission.READ_FILE, Permission.VIEW_CATALOG) + private val user = UserDto(999L, "Unit test", "User", "", null, permissions) + private val userDetails = UserDetails(user) + + private val userJwt = "eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiI5OTkiLCJleHAiOjE2Njk2MTc1MDcsInBlcm1zIjoiWzIsMCwzXSIsInR5cGUiOjB9.bg8hbTRsWOcx4te3L0vi8WNPXWLZO-heS7bNsO_FBpkRPy4l-MtdLOa6hx_-pXbZ" + private val groupTokenJwt = "eyJhbGciOiJIUzM4NCJ9.eyJzdWIiOiJhMDIyZWU3YS03NGY5LTNjYTYtYmYwZC04ZTg3OWE2NjRhOWUifQ.VaRqPJ30h8WUACPf8wVrjaxINQcc9xnbzGOcMesW_PbeN9rEGzgkgFEuV4TRGlOr" + private val groupTokenId = UUID.nameUUIDFromBytes("Unit test token".toByteArray()) @AfterEach internal fun afterEach() { clearAllMocks() } - private fun withParsedUserOutputDto(jwt: String, test: (UserDto) -> Unit) { - val serializedUser = jwtParser.parseClaimsJws(jwt) - .body.get(jwtClaimUser, String::class.java) + @Test + fun buildUserJwt_normalBehavior_buildJwtWithValidSubject() { + // Arrange + // Act + val jwt = jwtLogic.buildUserJwt(userDetails) - test(objectMapper.readValue(serializedUser)) + // Assert + val parsedJwt = jwtLogic.parseUserJwt(jwt) + assertEquals(user.id.toString(), parsedJwt.id) } @Test - fun buildJwt_userDetails_normalBehavior_returnsJwtStringWithValidUser() { - val userDetails = UserDetails(user) + fun buildUserJwt_normalBehavior_buildJwtWithValidType() { + // Arrange + // Act + val jwt = jwtLogic.buildUserJwt(userDetails) - val builtJwt = jwtService.buildJwt(userDetails) - - withParsedUserOutputDto(builtJwt) { parsedUser -> - assertEquals(user, parsedUser) - } + // Assert + val parsedJwt = jwtLogic.parseUserJwt(jwt) + assertFalse(parsedJwt.isGroup) } @Test - fun buildJwt_user_normalBehavior_returnsJwtStringWithValidUser() { - val builtJwt = jwtService.buildJwt(user) + fun buildUserJwt_normalBehavior_buildJwtWithValidPermissions() { + // Arrange + // Act + val jwt = jwtLogic.buildUserJwt(userDetails) - withParsedUserOutputDto(builtJwt) { parsedUser -> - assertEquals(user, parsedUser) - } + // Assert + val parsedJwt = jwtLogic.parseUserJwt(jwt) + assertEquals(userDetails.authorities, parsedJwt.authorities) } @Test - fun buildJwt_user_normalBehavior_returnsJwtStringWithValidSubject() { - val builtJwt = jwtService.buildJwt(user) - val jwtSubject = jwtParser.parseClaimsJws(builtJwt).body.subject + fun buildGroupTokenIdJwt_normalBehavior_buildJwtWithValidSubject(){ + // Arrange + // Act + val jwt = jwtLogic.buildGroupTokenIdJwt(groupTokenId) - assertEquals(user.id.toString(), jwtSubject) + // Assert + val parsedGroupId = jwtLogic.parseGroupTokenIdJwt(jwt) + assertEquals(groupTokenId, parsedGroupId) } @Test - fun buildJwt_user_returnsJwtWithValidExpirationDate() { - val jwtExpectedExpirationDate = Instant.now().plusSeconds(securityProperties.jwtDuration) + fun parseUserJwt_normalBehavior_returnsUserWithValidId() { + // Arrange + // Act + val user = jwtLogic.parseUserJwt(userJwt) - val builtJwt = jwtService.buildJwt(user) - val jwtExpiration = jwtParser.parseClaimsJws(builtJwt) - .body.expiration.toInstant() - - // Check if it's between 1 second - assertTrue { jwtExpiration.isAround(jwtExpectedExpirationDate) } + // Assert + assertEquals(userDetails.id, user.id) } - // parseJwt() + @Test + fun parseUserJwt_normalBehavior_returnsUserWithValidType() { + // Arrange + // Act + val user = jwtLogic.parseUserJwt(userJwt) + + // Assert + assertFalse(user.isGroup) + } @Test - fun parseJwt_normalBehavior_returnsExpectedUser() { - val jwt = jwtService.buildJwt(user) - val parsedUser = jwtService.parseJwt(jwt) + fun parseUserJwt_normalBehavior_returnsUserWithValidPermissions() { + // Arrange + // Act + val user = jwtLogic.parseUserJwt(userJwt) - assertEquals(user, parsedUser) + // Assert + assertEquals(userDetails.authorities, user.authorities) + } + + @Test + fun parseGroupTokenId_normalBehavior_returnsValidGroupTokenId() { + // Arrange + // Act + val parsedGroupTokenId = jwtLogic.parseGroupTokenIdJwt(groupTokenJwt) + + // Assert + assertEquals(groupTokenId, parsedGroupTokenId) } } diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultRecipeLogicTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultRecipeLogicTest.kt index 101da52..3fe6d84 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultRecipeLogicTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultRecipeLogicTest.kt @@ -1,8 +1,9 @@ package dev.fyloz.colorrecipesexplorer.logic import dev.fyloz.colorrecipesexplorer.dtos.* +import dev.fyloz.colorrecipesexplorer.dtos.account.GroupDto import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException -import dev.fyloz.colorrecipesexplorer.logic.users.GroupLogic +import dev.fyloz.colorrecipesexplorer.logic.account.GroupLogic import dev.fyloz.colorrecipesexplorer.service.RecipeService import io.mockk.* import org.junit.jupiter.api.AfterEach @@ -213,4 +214,4 @@ class DefaultRecipeLogicTest { mixLogicMock.updateLocations(any()) } } -} \ No newline at end of file +} diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultRecipeStepLogicTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultRecipeStepLogicTest.kt index 23aac43..ba93fe6 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultRecipeStepLogicTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultRecipeStepLogicTest.kt @@ -1,6 +1,6 @@ package dev.fyloz.colorrecipesexplorer.logic -import dev.fyloz.colorrecipesexplorer.dtos.GroupDto +import dev.fyloz.colorrecipesexplorer.dtos.account.GroupDto import dev.fyloz.colorrecipesexplorer.dtos.RecipeGroupInformationDto import dev.fyloz.colorrecipesexplorer.dtos.RecipeStepDto import dev.fyloz.colorrecipesexplorer.exception.InvalidPositionError @@ -57,4 +57,4 @@ class DefaultRecipeStepLogicTest { // Assert assertThrows { recipeStepLogic.validateGroupInformationSteps(groupInfo) } } -} \ No newline at end of file +} diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultGroupLogicTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultGroupLogicTest.kt index 1f208b1..83d3a3e 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultGroupLogicTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultGroupLogicTest.kt @@ -1,11 +1,9 @@ package dev.fyloz.colorrecipesexplorer.logic.account -import dev.fyloz.colorrecipesexplorer.dtos.GroupDto -import dev.fyloz.colorrecipesexplorer.dtos.UserDto +import dev.fyloz.colorrecipesexplorer.dtos.account.GroupDto +import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException -import dev.fyloz.colorrecipesexplorer.logic.users.DefaultGroupLogic -import dev.fyloz.colorrecipesexplorer.logic.users.UserLogic -import dev.fyloz.colorrecipesexplorer.service.GroupService +import dev.fyloz.colorrecipesexplorer.service.account.GroupService import io.mockk.* import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test @@ -25,9 +23,7 @@ class DefaultGroupLogicTest { } private val userLogicMock = mockk { every { getAllByGroup(any()) } returns listOf() - every { getById(any(), any(), any()) } returns user - every { getDefaultGroupUser(any()) } returns user - every { saveDefaultGroupUser(any()) } just runs + every { getById(any(), any()) } returns user every { deleteById(any()) } just runs } @@ -72,16 +68,4 @@ class DefaultGroupLogicTest { // Assert assertThrows { groupLogic.update(group) } } - - @Test - fun deleteById_normalBehavior_callsDeleteByIdInUserLogicWithDefaultGroupUserId() { - // Arrange - // Act - groupLogic.deleteById(group.id) - - // Assert - verify { - userLogicMock.deleteById(group.defaultGroupUserId) - } - } -} \ No newline at end of file +} diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultGroupTokenLogicTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultGroupTokenLogicTest.kt new file mode 100644 index 0000000..03b66e0 --- /dev/null +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultGroupTokenLogicTest.kt @@ -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() + private val groupLogicMock = mockk() + private val jwtLogicMock = mockk() + + private val enabledTokenCache = hashSetOf() + + 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()) } returns groupToken + + // Act + groupTokenLogic.getById(groupTokenIdStr) + + // Assert + verify { + groupTokenLogic.getById(groupTokenId) + } + } + + @Test + fun getById_uuid_normalBehavior_returnsFromService() { + // Arrange + every { groupTokenServiceMock.getById(any()) } returns groupToken + + // Act + val actualGroupToken = groupTokenLogic.getById(groupTokenId) + + // Assert + assertSame(groupToken, actualGroupToken) + } + + @Test + fun getById_uuid_notFound_throwsNotFoundException() { + // Arrange + every { groupTokenServiceMock.getById(any()) } returns null + + // Act + // Assert + assertThrows { groupTokenLogic.getById(groupTokenId) } + } + + @Test + fun getIdForRequest_normalBehavior_returnsGroupTokenIdFromRequest() { + // Arrange + val request = mockk() + val cookie = mockk { + every { value } returns "Bearer$groupTokenIdStr" + } + + mockkStatic(WebUtils::class) { + every { WebUtils.getCookie(any(), Constants.CookieNames.GROUP_TOKEN) } returns cookie + } + } + + @Test + fun save_normalBehavior_callsSaveInService() { + // Arrange + every { groupTokenServiceMock.existsByName(any()) } returns false + every { groupTokenServiceMock.existsById(any()) } returns false + every { groupTokenServiceMock.save(any()) } returns groupToken + every { groupLogicMock.getById(any()) } returns group + + // Act + 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 { groupTokenLogic.save(groupTokenSaveDto) } + } + + @Test + fun enable_normalBehavior_savesTokenInService() { + // Arrange + every { groupTokenServiceMock.save(any()) } returns groupToken + every { groupTokenLogic.getById(any()) } returns groupToken + + // Act + groupTokenLogic.enable(groupTokenIdStr) + + // Assert + verify { + groupTokenServiceMock.save(match { + it.id == groupTokenId && it.name == groupTokenName && it.enabled + }) + } + } + + @Test + fun enable_normalBehavior_addsIdToEnabledTokensCache() { + // Arrange + every { groupTokenServiceMock.save(any()) } returns groupToken + every { groupTokenLogic.getById(any()) } returns groupToken + + // Act + groupTokenLogic.enable(groupTokenIdStr) + + // Assert + assertContains(enabledTokenCache, groupTokenIdStr) + } + + @Test + fun disable_normalBehavior_savesTokenInService() { + // Arrange + every { groupTokenServiceMock.save(any()) } returns groupToken + every { groupTokenLogic.getById(any()) } returns groupToken + + // Act + groupTokenLogic.disable(groupTokenIdStr) + + // Assert + verify { + groupTokenServiceMock.save(match { + it.id == groupTokenId && it.name == groupTokenName && !it.enabled + }) + } + } + + @Test + fun disable_normalBehavior_removesIdFromEnabledTokensCache() { + // Arrange + every { groupTokenServiceMock.save(any()) } returns groupToken + every { groupTokenLogic.getById(any()) } returns groupToken + + // Act + groupTokenLogic.disable(groupTokenIdStr) + + // Assert + assertFalse(enabledTokenCache.contains(groupTokenIdStr)) + } + + @Test + fun deleteById_normalBehavior_savesDeletedTokenInService() { + // Arrange + every { groupTokenLogic.getById(any()) } 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()) } answers { groupToken } + every { groupTokenServiceMock.save(any()) } answers { firstArg() } + + // Act + groupTokenLogic.deleteById(groupTokenIdStr) + + // Assert + assertFalse(enabledTokenCache.contains(groupTokenIdStr)) + } + + private fun withMockRandomUUID(uuids: List? = null, block: () -> Unit) { + mockkStatic(UUID::class) { + if (uuids == null) { + every { UUID.randomUUID() } returns groupTokenId + } else { + every { UUID.randomUUID() } returnsMany uuids + } + + block() + } + } +} diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultUserLogicTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultUserLogicTest.kt index d4f8b32..9e6dc89 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultUserLogicTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultUserLogicTest.kt @@ -1,15 +1,13 @@ package dev.fyloz.colorrecipesexplorer.logic.account -import dev.fyloz.colorrecipesexplorer.dtos.GroupDto -import dev.fyloz.colorrecipesexplorer.dtos.UserDto -import dev.fyloz.colorrecipesexplorer.dtos.UserSaveDto -import dev.fyloz.colorrecipesexplorer.dtos.UserUpdateDto +import dev.fyloz.colorrecipesexplorer.dtos.account.GroupDto +import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto +import dev.fyloz.colorrecipesexplorer.dtos.account.UserSaveDto +import dev.fyloz.colorrecipesexplorer.dtos.account.UserUpdateDto import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException import dev.fyloz.colorrecipesexplorer.exception.NotFoundException -import dev.fyloz.colorrecipesexplorer.logic.users.DefaultUserLogic -import dev.fyloz.colorrecipesexplorer.logic.users.GroupLogic import dev.fyloz.colorrecipesexplorer.model.account.Permission -import dev.fyloz.colorrecipesexplorer.service.UserService +import dev.fyloz.colorrecipesexplorer.service.account.UserService import io.mockk.* import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test @@ -24,11 +22,10 @@ class DefaultUserLogicTest { private val userServiceMock = mockk { every { existsById(any()) } returns false every { existsByFirstNameAndLastName(any(), any(), any()) } returns false - every { getAll(any(), any()) } returns listOf() + every { getAll(any()) } returns listOf() every { getAllByGroup(any()) } returns listOf() - every { getById(any(), any(), any()) } returns user + every { getById(any(), any()) } returns user every { getByFirstNameAndLastName(any(), any()) } returns user - every { getDefaultGroupUser(any()) } returns user } private val groupLogicMock = mockk { every { getById(any()) } returns group @@ -46,8 +43,7 @@ class DefaultUserLogicTest { user.password, null, user.permissions, - user.isSystemUser, - user.isDefaultGroupUser + user.isSystemUser ) private val userUpdateDto = UserUpdateDto(user.id, user.firstName, user.lastName, null, listOf()) @@ -64,7 +60,7 @@ class DefaultUserLogicTest { // Assert verify { - userServiceMock.getAll(isSystemUser = false, isDefaultGroupUser = false) + userServiceMock.getAll(isSystemUser = false) } confirmVerified(userServiceMock) } @@ -90,7 +86,7 @@ class DefaultUserLogicTest { // Assert verify { - userLogic.getById(user.id, isSystemUser = false, isDefaultGroupUser = false) + userLogic.getById(user.id, isSystemUser = false) } } @@ -98,11 +94,11 @@ class DefaultUserLogicTest { fun getById_normalBehavior_callsGetByIdInService() { // Arrange // Act - userLogic.getById(user.id, isSystemUser = false, isDefaultGroupUser = true) + userLogic.getById(user.id, isSystemUser = false) // Assert verify { - userServiceMock.getById(user.id, isSystemUser = false, isDefaultGroupUser = true) + userServiceMock.getById(user.id, isSystemUser = false) } confirmVerified(userServiceMock) } @@ -110,54 +106,13 @@ class DefaultUserLogicTest { @Test fun getById_notFound_throwsNotFoundException() { // Arrange - every { userServiceMock.getById(any(), any(), any()) } returns null + every { userServiceMock.getById(any(), any()) } returns null // Act // Assert assertThrows { userLogic.getById(user.id) } } - @Test - fun getDefaultGroupUser_normalBehavior_callsGetDefaultGroupUserInService() { - // Arrange - // Act - userLogic.getDefaultGroupUser(group) - - // Assert - verify { - userServiceMock.getDefaultGroupUser(group) - } - confirmVerified(userServiceMock) - } - - @Test - fun getDefaultGroupUser_notFound_throwsNotFoundException() { - // Arrange - every { userServiceMock.getDefaultGroupUser(any()) } returns null - - // Act - // Assert - assertThrows { userLogic.getDefaultGroupUser(group) } - } - - @Test - fun saveDefaultGroupUser_normalBehavior_callsSaveWithValidSaveDto() { - // Arrange - every { userLogic.save(any()) } returns user - - val expectedSaveDto = UserSaveDto( - group.defaultGroupUserId, group.name, "User", group.name, group.id, listOf(), isDefaultGroupUser = true - ) - - // Act - userLogic.saveDefaultGroupUser(group) - - // Assert - verify { - userLogic.save(expectedSaveDto) - } - } - @Test fun save_dto_normalBehavior_callsSaveWithValidUser() { // Arrange @@ -210,7 +165,7 @@ class DefaultUserLogicTest { @Test fun update_dto_normalBehavior_callsUpdateWithValidUser() { // Arrange - every { userLogic.getById(any(), any(), any()) } returns user + every { userLogic.getById(any(), any()) } returns user every { userLogic.update(any()) } returns user // Act @@ -303,4 +258,4 @@ class DefaultUserLogicTest { userLogic.update(user) } } -} \ No newline at end of file +}