From 37b597936b92c1541ead6a6f56c75d64b10ee158 Mon Sep 17 00:00:00 2001 From: FyloZ Date: Thu, 28 Apr 2022 15:02:01 -0400 Subject: [PATCH] #30 Add login from group tokens --- .../fyloz/colorrecipesexplorer/Constants.kt | 39 +++++++---- .../security/GroupAuthenticationToken.kt | 13 ++++ .../GroupTokenAuthenticationProvider.kt | 37 +++++++++++ .../config/security/SecurityConfig.kt | 50 ++++++++++---- .../filters/GroupTokenAuthenticationFilter.kt | 35 ++++++++++ .../filters/JwtAuthenticationFilter.kt | 63 ++++++++++++++++++ .../JwtAuthorizationFilter.kt} | 66 ++----------------- .../UsernamePasswordAuthenticationFilter.kt | 39 +++++++++++ .../dtos/account/UserDto.kt | 25 ++++--- .../logic/account/GroupLogic.kt | 5 +- .../logic/account/GroupTokenLogic.kt | 7 +- .../logic/account/JwtLogic.kt | 19 ++---- .../logic/account/UserLogic.kt | 5 +- .../rest/account/GroupTokenController.kt | 42 +++++++++--- .../fyloz/colorrecipesexplorer/utils/Http.kt | 32 +++++---- src/main/resources/application.properties | 2 + .../logic/DefaultRecipeLogicTest.kt | 1 + 17 files changed, 345 insertions(+), 135 deletions(-) create mode 100644 src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupAuthenticationToken.kt create mode 100644 src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupTokenAuthenticationProvider.kt create mode 100644 src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/GroupTokenAuthenticationFilter.kt create mode 100644 src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthenticationFilter.kt rename src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/{JwtFilters.kt => filters/JwtAuthorizationFilter.kt} (55%) create mode 100644 src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/UsernamePasswordAuthenticationFilter.kt diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/Constants.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/Constants.kt index 73fe84c..ff032b5 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/Constants.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/Constants.kt @@ -1,18 +1,30 @@ package dev.fyloz.colorrecipesexplorer object Constants { + var DEBUG_MODE = false // Not really a constant, but should never change after the app startup + object ControllerPaths { - const val COMPANY = "/api/company" - const val GROUP_TOKEN = "/api/account/group/token" - const val FILE = "/api/file" - const val GROUP = "/api/account/group" - const val INVENTORY = "/api/inventory" - const val MATERIAL = "/api/material" - const val MATERIAL_TYPE = "/api/materialtype" - const val MIX = "/api/recipe/mix" - const val RECIPE = "/api/recipe" - const val TOUCH_UP_KIT = "/api/touchupkit" - const val USER = "/api/account/user" + const val BASE_PATH = "/api" + + const val COMPANY = "$BASE_PATH/company" + const val GROUP_TOKEN = "$BASE_PATH/account/group/token" + const val FILE = "$BASE_PATH/file" + const val GROUP = "$BASE_PATH/account/group" + const val INVENTORY = "$BASE_PATH/inventory" + const val MATERIAL = "$BASE_PATH/material" + const val MATERIAL_TYPE = "$BASE_PATH/materialtype" + const val MIX = "$BASE_PATH/recipe/mix" + const val RECIPE = "$BASE_PATH/recipe" + const val TOUCH_UP_KIT = "$BASE_PATH/touchupkit" + const val USER = "$BASE_PATH/account/user" + + const val LOGIN = "$BASE_PATH/account/login" + const val GROUP_LOGIN = "$BASE_PATH/account/login/group" + } + + object CookieNames { + const val AUTHORIZATION = "Authorization" + const val GROUP_TOKEN = "Group-Token" } object FilePaths { @@ -24,6 +36,11 @@ object Constants { const val RECIPE_IMAGES = "$IMAGES/recipes" } + object HeaderNames { + const val ACCESS_CONTROL_EXPOSE_HEADERS = "Access-Control-Expose-Headers" + const val AUTHORIZATION = "Authorization" + } + object ModelNames { const val COMPANY = "Company" const val GROUP_TOKEN = "GroupToken" diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupAuthenticationToken.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupAuthenticationToken.kt new file mode 100644 index 0000000..9bb42a3 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupAuthenticationToken.kt @@ -0,0 +1,13 @@ +package dev.fyloz.colorrecipesexplorer.config.security + +import org.springframework.security.authentication.AbstractAuthenticationToken +import org.springframework.security.core.GrantedAuthority +import java.util.* + +class GroupAuthenticationToken(val id: String) : AbstractAuthenticationToken(null) { + override fun getPrincipal() = id + + // There is no credential needed to log in with a group token, just use the group token id + override fun getCredentials() = id +} + diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupTokenAuthenticationProvider.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupTokenAuthenticationProvider.kt new file mode 100644 index 0000000..8150f07 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/GroupTokenAuthenticationProvider.kt @@ -0,0 +1,37 @@ +package dev.fyloz.colorrecipesexplorer.config.security + +import dev.fyloz.colorrecipesexplorer.dtos.account.UserDetails +import dev.fyloz.colorrecipesexplorer.exception.NotFoundException +import dev.fyloz.colorrecipesexplorer.logic.account.GroupTokenLogic +import org.springframework.security.authentication.AuthenticationProvider +import org.springframework.security.authentication.BadCredentialsException +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.Authentication +import java.util.* + +class GroupTokenAuthenticationProvider(private val groupTokenLogic: GroupTokenLogic) : AuthenticationProvider { + override fun authenticate(authentication: Authentication): Authentication { + val groupAuthenticationToken = authentication as GroupAuthenticationToken + + val groupTokenId = parseGroupTokenId(groupAuthenticationToken.id) + val groupToken = retrieveGroupToken(groupTokenId) + + val userDetails = UserDetails(groupToken.id, groupToken.name, "", groupToken.group.id, groupToken.group.permissions) + return UsernamePasswordAuthenticationToken(userDetails, null, userDetails.authorities) + } + + override fun supports(authentication: Class<*>) = + authentication.isAssignableFrom(GroupAuthenticationToken::class.java) + + private fun parseGroupTokenId(id: String) = try { + UUID.fromString(id) + } catch (_: IllegalArgumentException) { + throw BadCredentialsException("Group token id must be a valid UUID") + } + + private fun retrieveGroupToken(id: UUID) = try { + groupTokenLogic.getById(id) + } catch (_: NotFoundException) { + throw BadCredentialsException("Failed to find group token with id '$id'") + } +} \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt index 188e57b..34485d3 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt @@ -1,8 +1,13 @@ package dev.fyloz.colorrecipesexplorer.config.security +import dev.fyloz.colorrecipesexplorer.Constants import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties +import dev.fyloz.colorrecipesexplorer.config.security.filters.GroupTokenAuthenticationFilter +import dev.fyloz.colorrecipesexplorer.config.security.filters.UsernamePasswordAuthenticationFilter +import dev.fyloz.colorrecipesexplorer.config.security.filters.JwtAuthorizationFilter import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto import dev.fyloz.colorrecipesexplorer.emergencyMode +import dev.fyloz.colorrecipesexplorer.logic.account.GroupTokenLogic import dev.fyloz.colorrecipesexplorer.logic.account.JwtLogic import dev.fyloz.colorrecipesexplorer.logic.account.UserDetailsLogic import dev.fyloz.colorrecipesexplorer.logic.account.UserLogic @@ -14,6 +19,7 @@ import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Lazy import org.springframework.context.annotation.Profile +import org.springframework.core.annotation.Order import org.springframework.core.env.Environment import org.springframework.http.HttpMethod import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder @@ -25,6 +31,7 @@ import org.springframework.security.config.http.SessionCreationPolicy import org.springframework.security.core.AuthenticationException import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder import org.springframework.security.web.AuthenticationEntryPoint +import org.springframework.security.web.authentication.www.BasicAuthenticationFilter import org.springframework.stereotype.Component import org.springframework.web.cors.CorsConfiguration import org.springframework.web.cors.UrlBasedCorsConfigurationSource @@ -39,13 +46,13 @@ private const val rootUserLastName = "User" abstract class BaseSecurityConfig( private val userDetailsLogic: UserDetailsLogic, private val jwtLogic: JwtLogic, + private val groupTokenLogic: GroupTokenLogic, private val environment: Environment, protected val securityProperties: CreSecurityProperties ) : WebSecurityConfigurerAdapter() { protected abstract val logger: Logger protected val passwordEncoder = BCryptPasswordEncoder() - var debugMode = false @Bean open fun passwordEncoder() = @@ -69,21 +76,30 @@ abstract class BaseSecurityConfig( } override fun configure(authBuilder: AuthenticationManagerBuilder) { - authBuilder.userDetailsService(userDetailsLogic).passwordEncoder(passwordEncoder) + authBuilder + .authenticationProvider(GroupTokenAuthenticationProvider(groupTokenLogic)) + .userDetailsService(userDetailsLogic).passwordEncoder(passwordEncoder) } override fun configure(http: HttpSecurity) { + val authManager = authenticationManager() + http .headers().frameOptions().disable() .and() .csrf().disable() - .addFilter( - JwtAuthenticationFilter( - authenticationManager(), + .addFilterBefore( + GroupTokenAuthenticationFilter(jwtLogic, securityProperties, authManager), + BasicAuthenticationFilter::class.java + ) + .addFilterBefore( + UsernamePasswordAuthenticationFilter( jwtLogic, securityProperties, + authManager, this::updateUserLoginTime - ) + ), + BasicAuthenticationFilter::class.java ) .addFilter( JwtAuthorizationFilter(jwtLogic, authenticationManager(), userDetailsLogic) @@ -91,11 +107,12 @@ abstract class BaseSecurityConfig( .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() - .antMatchers("/api/config/**").permitAll() // Allow access to logo and icon - .antMatchers("/api/login").permitAll() // Allow access to login - .antMatchers("**").fullyAuthenticated() +// .antMatchers("/api/config/**").permitAll() // Allow access to logo and icon +// .antMatchers("/api/account/login/group").permitAll() // Allow access to login +// .antMatchers("**").fullyAuthenticated() + .antMatchers("**").permitAll() - if (debugMode) { + if (Constants.DEBUG_MODE) { http .cors() } @@ -103,14 +120,17 @@ abstract class BaseSecurityConfig( @PostConstruct fun initDebugMode() { - debugMode = "debug" in environment.activeProfiles + val debugMode = "debug" in environment.activeProfiles if (debugMode) logger.warn("Debug mode is enabled, security will be decreased!") + + Constants.DEBUG_MODE = debugMode } protected open fun updateUserLoginTime(userId: Long) { } } +@Order(2) @Configuration @Profile("!emergency") @EnableWebSecurity @@ -120,9 +140,10 @@ class SecurityConfig( @Lazy userDetailsLogic: UserDetailsLogic, @Lazy private val userLogic: UserLogic, jwtLogic: JwtLogic, + groupTokenLogic: GroupTokenLogic, environment: Environment, securityProperties: CreSecurityProperties -) : BaseSecurityConfig(userDetailsLogic, jwtLogic, environment, securityProperties) { +) : BaseSecurityConfig(userDetailsLogic, jwtLogic, groupTokenLogic, environment, securityProperties) { override val logger = KotlinLogging.logger {} @PostConstruct @@ -168,9 +189,10 @@ class SecurityConfig( class EmergencySecurityConfig( userDetailsLogic: UserDetailsLogic, jwtLogic: JwtLogic, + groupTokenLogic: GroupTokenLogic, environment: Environment, securityProperties: CreSecurityProperties -) : BaseSecurityConfig(userDetailsLogic, jwtLogic, environment, securityProperties) { +) : BaseSecurityConfig(userDetailsLogic, jwtLogic, groupTokenLogic, environment, securityProperties) { override val logger = KotlinLogging.logger {} init { @@ -187,5 +209,5 @@ class RestAuthenticationEntryPoint : AuthenticationEntryPoint { ) = response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized") } -private class InvalidSystemUserException(userType: String, message: String) : +class InvalidSystemUserException(userType: String, message: String) : RuntimeException("Invalid $userType user: $message") diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/GroupTokenAuthenticationFilter.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/GroupTokenAuthenticationFilter.kt new file mode 100644 index 0000000..685bf8a --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/GroupTokenAuthenticationFilter.kt @@ -0,0 +1,35 @@ +package dev.fyloz.colorrecipesexplorer.config.security.filters + +import dev.fyloz.colorrecipesexplorer.Constants +import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties +import dev.fyloz.colorrecipesexplorer.config.security.GroupAuthenticationToken +import dev.fyloz.colorrecipesexplorer.dtos.account.UserDetails +import dev.fyloz.colorrecipesexplorer.logic.account.JwtLogic +import org.springframework.security.authentication.AuthenticationManager +import org.springframework.security.authentication.BadCredentialsException +import org.springframework.security.core.Authentication +import org.springframework.web.util.WebUtils +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +class GroupTokenAuthenticationFilter( + jwtLogic: JwtLogic, + securityProperties: CreSecurityProperties, + private val authManager: AuthenticationManager +) : JwtAuthenticationFilter(Constants.ControllerPaths.GROUP_LOGIN, securityProperties, jwtLogic) { + override fun attemptAuthentication(request: HttpServletRequest, response: HttpServletResponse): Authentication { + val groupTokenCookie = getGroupTokenCookie(request) + ?: throw BadCredentialsException("Required group token cookie was not present") + val groupTokenId = groupTokenCookie.value + + logger.debug("Login attempt for group token $groupTokenId") + return authManager.authenticate(GroupAuthenticationToken(groupTokenId)) + } + + override fun afterSuccessfulAuthentication(userDetails: UserDetails) { + logger.info("Successful login for group id '${userDetails.groupId}' using token '${userDetails.id}' (${userDetails.username})") + } + + private fun getGroupTokenCookie(request: HttpServletRequest) = + WebUtils.getCookie(request, Constants.CookieNames.GROUP_TOKEN) +} \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthenticationFilter.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthenticationFilter.kt new file mode 100644 index 0000000..95136eb --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthenticationFilter.kt @@ -0,0 +1,63 @@ +package dev.fyloz.colorrecipesexplorer.config.security.filters + +import dev.fyloz.colorrecipesexplorer.Constants +import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties +import dev.fyloz.colorrecipesexplorer.dtos.account.UserDetails +import dev.fyloz.colorrecipesexplorer.logic.account.JwtLogic +import dev.fyloz.colorrecipesexplorer.utils.addCookie +import org.springframework.http.HttpMethod +import org.springframework.security.core.Authentication +import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter +import org.springframework.security.web.util.matcher.AntPathRequestMatcher +import javax.servlet.FilterChain +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +abstract class JwtAuthenticationFilter( + filterProcessesUrl: String, + private val securityProperties: CreSecurityProperties, + private val jwtLogic: JwtLogic +) : + AbstractAuthenticationProcessingFilter( + AntPathRequestMatcher(filterProcessesUrl, HttpMethod.POST.toString()) + ) { + override fun successfulAuthentication( + request: HttpServletRequest, + response: HttpServletResponse, + chain: FilterChain, + auth: Authentication + ) { + val userDetails = auth.principal as UserDetails + val token = jwtLogic.buildJwt(userDetails) + + addAuthorizationHeaders(response, token) + addAuthorizationCookie(response, token) + + afterSuccessfulAuthentication(userDetails) + } + + protected abstract fun afterSuccessfulAuthentication(userDetails: UserDetails) + + private fun addAuthorizationHeaders(response: HttpServletResponse, token: String) { + response.addHeader(Constants.HeaderNames.ACCESS_CONTROL_EXPOSE_HEADERS, Constants.HeaderNames.AUTHORIZATION) + response.addHeader(Constants.HeaderNames.AUTHORIZATION, "$BEARER_TOKEN_PREFIX $token") + } + + private fun addAuthorizationCookie(response: HttpServletResponse, token: String) { + response.addCookie(Constants.CookieNames.AUTHORIZATION, BEARER_TOKEN_PREFIX + token) { + httpOnly = AUTHORIZATION_COOKIE_HTTP_ONLY + sameSite = AUTHORIZATION_COOKIE_SAME_SITE + secure = !Constants.DEBUG_MODE + maxAge = securityProperties.jwtDuration / 1000 + path = AUTHORIZATION_COOKIE_PATH + } + } + + companion object { + private const val AUTHORIZATION_COOKIE_HTTP_ONLY = true + private const val AUTHORIZATION_COOKIE_SAME_SITE = true + private const val AUTHORIZATION_COOKIE_PATH = Constants.ControllerPaths.BASE_PATH + + private const val BEARER_TOKEN_PREFIX = "Bearer" + } +} \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/JwtFilters.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthorizationFilter.kt similarity index 55% rename from src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/JwtFilters.kt rename to src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthorizationFilter.kt index b7b5f48..8d1a7f5 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/JwtFilters.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/JwtAuthorizationFilter.kt @@ -1,75 +1,20 @@ -package dev.fyloz.colorrecipesexplorer.config.security +package dev.fyloz.colorrecipesexplorer.config.security.filters -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper -import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties +import dev.fyloz.colorrecipesexplorer.Constants import dev.fyloz.colorrecipesexplorer.dtos.account.UserDetails -import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto -import dev.fyloz.colorrecipesexplorer.dtos.account.UserLoginRequestDto import dev.fyloz.colorrecipesexplorer.exception.NotFoundException import dev.fyloz.colorrecipesexplorer.logic.account.JwtLogic import dev.fyloz.colorrecipesexplorer.logic.account.UserDetailsLogic -import dev.fyloz.colorrecipesexplorer.utils.addCookie import io.jsonwebtoken.ExpiredJwtException import org.springframework.security.authentication.AuthenticationManager import org.springframework.security.authentication.UsernamePasswordAuthenticationToken -import org.springframework.security.core.Authentication import org.springframework.security.core.context.SecurityContextHolder -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter import org.springframework.security.web.authentication.www.BasicAuthenticationFilter import org.springframework.web.util.WebUtils import javax.servlet.FilterChain import javax.servlet.http.HttpServletRequest import javax.servlet.http.HttpServletResponse -const val authorizationCookieName = "Authorization" -const val defaultGroupCookieName = "Default-Group" -val blacklistedJwtTokens = mutableListOf() // Not working, move to a cache or something - -class JwtAuthenticationFilter( - private val authManager: AuthenticationManager, - private val jwtLogic: JwtLogic, - private val securityProperties: CreSecurityProperties, - private val updateUserLoginTime: (Long) -> Unit -) : UsernamePasswordAuthenticationFilter() { - private var debugMode = false - - init { - setFilterProcessesUrl("/api/login") - debugMode = "debug" in environment.activeProfiles - } - - override fun attemptAuthentication(request: HttpServletRequest, response: HttpServletResponse): Authentication { - val loginRequest = jacksonObjectMapper().readValue(request.inputStream, UserLoginRequestDto::class.java) - logger.debug("Login attempt for user ${loginRequest.id}...") - return authManager.authenticate(UsernamePasswordAuthenticationToken(loginRequest.id, loginRequest.password)) - } - - override fun successfulAuthentication( - request: HttpServletRequest, - response: HttpServletResponse, - chain: FilterChain, - auth: Authentication - ) { - val userDetails = auth.principal as UserDetails - val token = jwtLogic.buildJwt(userDetails) - - with(userDetails.user) { - logger.info("User ${this.id} (${this.firstName} ${this.lastName}) has logged in successfully") - } - - response.addHeader("Access-Control-Expose-Headers", authorizationCookieName) - response.addHeader(authorizationCookieName, "Bearer $token") - response.addCookie(authorizationCookieName, "Bearer$token") { - httpOnly = true - sameSite = true - secure = !debugMode - maxAge = securityProperties.jwtDuration / 1000 - } - - updateUserLoginTime(userDetails.user.id) - } -} - class JwtAuthorizationFilter( private val jwtLogic: JwtLogic, authenticationManager: AuthenticationManager, @@ -77,12 +22,12 @@ class JwtAuthorizationFilter( ) : BasicAuthenticationFilter(authenticationManager) { override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, chain: FilterChain) { fun tryLoginFromBearer(): Boolean { - val authorizationCookie = WebUtils.getCookie(request, authorizationCookieName) + val authorizationCookie = WebUtils.getCookie(request, Constants.HeaderNames.AUTHORIZATION) // Check for an authorization token cookie or header val authorizationToken = if (authorizationCookie != null) authorizationCookie.value else - request.getHeader(authorizationCookieName) + request.getHeader(Constants.HeaderNames.AUTHORIZATION) // An authorization token is valid if it starts with "Bearer", is not expired and is not blacklisted if (authorizationToken != null && authorizationToken.startsWith("Bearer") && authorizationToken !in blacklistedJwtTokens) { @@ -103,6 +48,7 @@ class JwtAuthorizationFilter( if (!tryLoginFromBearer()) tryLoginFromDefaultGroupCookie() + chain.doFilter(request, response) } @@ -115,7 +61,7 @@ class JwtAuthorizationFilter( } } - private fun getAuthenticationToken(user: UserDto) = + private fun getAuthenticationToken(user: UserDetails) = UsernamePasswordAuthenticationToken(user.id, null, user.authorities) private fun getAuthenticationToken(userId: Long): UsernamePasswordAuthenticationToken? = try { diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/UsernamePasswordAuthenticationFilter.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/UsernamePasswordAuthenticationFilter.kt new file mode 100644 index 0000000..d9d91ec --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/filters/UsernamePasswordAuthenticationFilter.kt @@ -0,0 +1,39 @@ +package dev.fyloz.colorrecipesexplorer.config.security.filters + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import dev.fyloz.colorrecipesexplorer.Constants +import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties +import dev.fyloz.colorrecipesexplorer.dtos.account.UserDetails +import dev.fyloz.colorrecipesexplorer.dtos.account.UserLoginRequestDto +import dev.fyloz.colorrecipesexplorer.logic.account.JwtLogic +import org.springframework.security.authentication.AuthenticationManager +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.Authentication +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +const val defaultGroupCookieName = "Default-Group" +val blacklistedJwtTokens = mutableListOf() + +class UsernamePasswordAuthenticationFilter( + jwtLogic: JwtLogic, + securityProperties: CreSecurityProperties, + private val authManager: AuthenticationManager, + private val updateUserLoginTime: (Long) -> Unit +) : JwtAuthenticationFilter(Constants.ControllerPaths.LOGIN, securityProperties, jwtLogic) { + override fun attemptAuthentication(request: HttpServletRequest, response: HttpServletResponse): Authentication { + val loginRequest = getLoginRequest(request) + val authenticationToken = UsernamePasswordAuthenticationToken(loginRequest.id, loginRequest.password) + + logger.debug("Login attempt for user ${loginRequest.id}") + return authManager.authenticate(authenticationToken) + } + + override fun afterSuccessfulAuthentication(userDetails: UserDetails) { + updateUserLoginTime(userDetails.id as Long) + logger.info("User ${userDetails.id} (${userDetails.username}) has logged in successfully") + } + + private fun getLoginRequest(request: HttpServletRequest) = + jacksonObjectMapper().readValue(request.inputStream, UserLoginRequestDto::class.java) +} \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/UserDto.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/UserDto.kt index 32f2a5f..525a333 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/UserDto.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/account/UserDto.kt @@ -35,10 +35,7 @@ data class UserDto( val isSystemUser: Boolean = false ) : EntityDto { @get:JsonIgnore - val authorities - get() = permissions - .map { it.toAuthority() } - .toMutableSet() + val fullName = "$firstName $lastName" } data class UserSaveDto( @@ -81,10 +78,22 @@ data class UserUpdateDto( data class UserLoginRequestDto(val id: Long, val password: String) -class UserDetails(val user: UserDto) : SpringUserDetails { - override fun getPassword() = user.password - override fun getUsername() = user.id.toString() - override fun getAuthorities() = user.authorities +class UserDetails( + val id: Any, + private val username: String, + private val password: String, + val groupId: Long?, + val permissions: Collection +) : SpringUserDetails { + constructor(user: UserDto) : this(user.id, user.fullName, user.password, user.group?.id, user.permissions) + + override fun getUsername() = username + override fun getPassword() = password + + @JsonIgnore + override fun getAuthorities() = permissions + .map { it.toAuthority() } + .toMutableList() override fun isAccountNonExpired() = true override fun isAccountNonLocked() = true diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupLogic.kt index 1a734df..8b60e77 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupLogic.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupLogic.kt @@ -2,7 +2,6 @@ package dev.fyloz.colorrecipesexplorer.logic.account import dev.fyloz.colorrecipesexplorer.Constants import dev.fyloz.colorrecipesexplorer.config.annotations.LogicComponent -import dev.fyloz.colorrecipesexplorer.config.security.defaultGroupCookieName import dev.fyloz.colorrecipesexplorer.dtos.account.GroupDto import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto import dev.fyloz.colorrecipesexplorer.exception.NoDefaultGroupException @@ -34,7 +33,7 @@ class DefaultGroupLogic(service: GroupService, private val userLogic: UserLogic) override fun getUsersForGroup(id: Long) = userLogic.getAllByGroup(getById(id)) override fun getRequestDefaultGroup(request: HttpServletRequest): GroupDto { - val defaultGroupCookie = WebUtils.getCookie(request, defaultGroupCookieName) + val defaultGroupCookie = WebUtils.getCookie(request, Constants.CookieNames.GROUP_TOKEN) ?: throw NoDefaultGroupException() val defaultGroupUser = userLogic.getById( defaultGroupCookie.value.toLong(), @@ -48,7 +47,7 @@ class DefaultGroupLogic(service: GroupService, private val userLogic: UserLogic) val defaultGroupUser = userLogic.getDefaultGroupUser(getById(id)) response.addHeader( "Set-Cookie", - "$defaultGroupCookieName=${defaultGroupUser.id}; Max-Age=$defaultGroupCookieMaxAge; Path=/api; HttpOnly; Secure; SameSite=strict" + "${Constants.CookieNames.GROUP_TOKEN}=${defaultGroupUser.id}; Max-Age=$defaultGroupCookieMaxAge; Path=/api; HttpOnly; Secure; SameSite=strict" ) } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupTokenLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupTokenLogic.kt index 12211ca..da5d927 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupTokenLogic.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/GroupTokenLogic.kt @@ -13,6 +13,7 @@ import java.util.UUID interface GroupTokenLogic { fun getAll(): Collection fun getById(id: String): GroupTokenDto + fun getById(id: UUID): GroupTokenDto fun save(dto: GroupTokenSaveDto): GroupTokenDto fun deleteById(id: String) } @@ -25,8 +26,10 @@ class DefaultGroupTokenLogic(private val service: GroupTokenService, private val override fun getAll() = service.getAll() - override fun getById(id: String) = - service.getById(UUID.fromString(id)) ?: throw notFoundException(value = id) + override fun getById(id: String) = getById(UUID.fromString(id)) + + override fun getById(id: UUID) = + service.getById(id) ?: throw notFoundException(value = id) override fun save(dto: GroupTokenSaveDto): GroupTokenDto { throwIfNameAlreadyExists(dto.name) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/JwtLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/JwtLogic.kt index 3efbf51..3897561 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/JwtLogic.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/JwtLogic.kt @@ -4,7 +4,6 @@ import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.readValue import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties import dev.fyloz.colorrecipesexplorer.dtos.account.UserDetails -import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto import dev.fyloz.colorrecipesexplorer.utils.base64encode import dev.fyloz.colorrecipesexplorer.utils.toDate import io.jsonwebtoken.Jwts @@ -20,11 +19,8 @@ interface JwtLogic { /** Build a JWT token for the given [userDetails]. */ fun buildJwt(userDetails: UserDetails): String - /** Build a JWT token for the given [user]. */ - fun buildJwt(user: UserDto): String - /** Parses a user from the given [jwt] token. */ - fun parseJwt(jwt: String): UserDto + fun parseJwt(jwt: String): UserDetails } @Service @@ -49,17 +45,14 @@ class DefaultJwtLogic( .build() } - override fun buildJwt(userDetails: UserDetails) = - buildJwt(userDetails.user) - - override fun buildJwt(user: UserDto): String = + override fun buildJwt(userDetails: UserDetails): String = jwtBuilder - .setSubject(user.id.toString()) + .setSubject(userDetails.id.toString()) .setExpiration(getCurrentExpirationDate()) - .claim(jwtClaimUser, user.serialize()) + .claim(jwtClaimUser, userDetails.serialize()) .compact() - override fun parseJwt(jwt: String): UserDto = + override fun parseJwt(jwt: String): UserDetails = with( jwtParser.parseClaimsJws(jwt) .body.get(jwtClaimUser, String::class.java) @@ -72,6 +65,6 @@ class DefaultJwtLogic( .plusSeconds(securityProperties.jwtDuration) .toDate() - private fun UserDto.serialize(): String = + private fun UserDetails.serialize(): String = objectMapper.writeValueAsString(this) } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserLogic.kt index 49d3f5d..07046bc 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserLogic.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/UserLogic.kt @@ -2,8 +2,7 @@ package dev.fyloz.colorrecipesexplorer.logic.account import dev.fyloz.colorrecipesexplorer.Constants import dev.fyloz.colorrecipesexplorer.config.annotations.LogicComponent -import dev.fyloz.colorrecipesexplorer.config.security.authorizationCookieName -import dev.fyloz.colorrecipesexplorer.config.security.blacklistedJwtTokens +import dev.fyloz.colorrecipesexplorer.config.security.filters.blacklistedJwtTokens import dev.fyloz.colorrecipesexplorer.dtos.account.GroupDto import dev.fyloz.colorrecipesexplorer.dtos.account.UserDto import dev.fyloz.colorrecipesexplorer.dtos.account.UserSaveDto @@ -139,7 +138,7 @@ class DefaultUserLogic( } override fun logout(request: HttpServletRequest) { - val authorizationCookie = WebUtils.getCookie(request, authorizationCookieName) + val authorizationCookie = WebUtils.getCookie(request, Constants.HeaderNames.AUTHORIZATION) if (authorizationCookie != null) { val authorizationToken = authorizationCookie.value if (authorizationToken != null && authorizationToken.startsWith("Bearer")) { diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupTokenController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupTokenController.kt index e2b24aa..79170eb 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupTokenController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/account/GroupTokenController.kt @@ -2,19 +2,16 @@ package dev.fyloz.colorrecipesexplorer.rest.account import dev.fyloz.colorrecipesexplorer.Constants import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeAdmin +import dev.fyloz.colorrecipesexplorer.dtos.account.GroupTokenDto import dev.fyloz.colorrecipesexplorer.dtos.account.GroupTokenSaveDto import dev.fyloz.colorrecipesexplorer.logic.account.GroupTokenLogic import dev.fyloz.colorrecipesexplorer.rest.created import dev.fyloz.colorrecipesexplorer.rest.noContent import dev.fyloz.colorrecipesexplorer.rest.ok +import dev.fyloz.colorrecipesexplorer.utils.addCookie import org.springframework.context.annotation.Profile -import org.springframework.web.bind.annotation.DeleteMapping -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.PathVariable -import org.springframework.web.bind.annotation.PostMapping -import org.springframework.web.bind.annotation.RequestBody -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController +import org.springframework.web.bind.annotation.* +import javax.servlet.http.HttpServletResponse import javax.validation.Valid @RestController @@ -28,13 +25,38 @@ class GroupTokenController(private val groupTokenLogic: GroupTokenLogic) { @GetMapping("{id}") fun getById(@PathVariable id: String) = ok(groupTokenLogic.getById(id)) - @PostMapping - fun save(@RequestBody @Valid dto: GroupTokenSaveDto) = with(groupTokenLogic.save(dto)) { - created(Constants.ControllerPaths.GROUP_TOKEN, this, this.id) + @GetMapping("{id}/cookie") + fun addCookieForId(@PathVariable id: String, response: HttpServletResponse) { + val groupToken = groupTokenLogic.getById(id) + addGroupTokenCookie(response, groupToken) } + @PostMapping + fun save(@RequestBody @Valid dto: GroupTokenSaveDto, response: HttpServletResponse) = + with(groupTokenLogic.save(dto)) { + addGroupTokenCookie(response, this) + created(Constants.ControllerPaths.GROUP_TOKEN, this, this.id) + } + @DeleteMapping("{id}") fun deleteById(@PathVariable id: String) = noContent { groupTokenLogic.deleteById(id) } + + private fun addGroupTokenCookie(response: HttpServletResponse, groupToken: GroupTokenDto) { + response.addCookie(Constants.CookieNames.GROUP_TOKEN, groupToken.id.toString()) { + httpOnly = GROUP_TOKEN_COOKIE_HTTP_ONLY + sameSite = GROUP_TOKEN_COOKIE_SAME_SITE + secure = !Constants.DEBUG_MODE + maxAge = GROUP_TOKEN_COOKIE_MAX_AGE + path = GROUP_TOKEN_COOKIE_PATH + } + } + + companion object { + private const val GROUP_TOKEN_COOKIE_HTTP_ONLY = true + private const val GROUP_TOKEN_COOKIE_SAME_SITE = true + private const val GROUP_TOKEN_COOKIE_MAX_AGE = Long.MAX_VALUE // This cookie should never expire + private const val GROUP_TOKEN_COOKIE_PATH = Constants.ControllerPaths.GROUP_LOGIN + } } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Http.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Http.kt index b9af339..60db2de 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Http.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Http.kt @@ -1,31 +1,40 @@ package dev.fyloz.colorrecipesexplorer.utils +import dev.fyloz.colorrecipesexplorer.Constants import javax.servlet.http.HttpServletResponse -private const val defaultCookieMaxAge = 3600L -private const val defaultCookieHttpOnly = true -private const val defaultCookieSameSite = true -private const val defaultCookieSecure = true data class CookieBuilderOptions( /** HTTP Only cookies cannot be access by Javascript clients. */ - var httpOnly: Boolean = defaultCookieHttpOnly, + var httpOnly: Boolean = DEFAULT_HTTP_ONLY, /** SameSite cookies are only sent in requests to their origin location. */ - var sameSite: Boolean = defaultCookieSameSite, + var sameSite: Boolean = DEFAULT_SAME_SITE, /** Secure cookies are only sent in HTTPS requests. */ - var secure: Boolean = defaultCookieSecure, + var secure: Boolean = DEFAULT_SECURE, /** Cookie's maximum age in seconds. */ - var maxAge: Long = defaultCookieMaxAge -) + var maxAge: Long = DEFAULT_MAX_AGE, + + /** The path for which the cookie will be sent. */ + var path: String = DEFAULT_PATH +) { + companion object { + private const val DEFAULT_MAX_AGE = 3600L + private const val DEFAULT_HTTP_ONLY = true + private const val DEFAULT_SAME_SITE = true + private const val DEFAULT_SECURE = true + private const val DEFAULT_PATH = Constants.ControllerPaths.BASE_PATH + } +} private enum class CookieBuilderOption(val optionName: String) { HTTP_ONLY("HttpOnly"), SAME_SITE("SameSite"), SECURE("Secure"), - MAX_AGE("Max-Age") + MAX_AGE("Max-Age"), + PATH("Path") } fun HttpServletResponse.addCookie(name: String, value: String, optionsBuilder: CookieBuilderOptions.() -> Unit) { @@ -50,6 +59,7 @@ private fun buildCookie(name: String, value: String, optionsBuilder: CookieBuild addBoolOption(CookieBuilderOption.SAME_SITE, options.sameSite) addBoolOption(CookieBuilderOption.SECURE, options.secure) addOption(CookieBuilderOption.MAX_AGE, options.maxAge) + addOption(CookieBuilderOption.PATH, options.path) return cookie.toString() -} +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 18c7f3f..a899897 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -31,3 +31,5 @@ spring.jackson.default-property-inclusion=non_null spring.profiles.active=@spring.profiles.active@ spring.sql.init.continue-on-error=true + +spring.autoconfigure.exclude=org.springframework.boot.autoconfigure.web.servlet.error.ErrorMvcAutoConfiguration \ No newline at end of file diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultRecipeLogicTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultRecipeLogicTest.kt index 47f5ee1..3fe6d84 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultRecipeLogicTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultRecipeLogicTest.kt @@ -1,6 +1,7 @@ package dev.fyloz.colorrecipesexplorer.logic import dev.fyloz.colorrecipesexplorer.dtos.* +import dev.fyloz.colorrecipesexplorer.dtos.account.GroupDto import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException import dev.fyloz.colorrecipesexplorer.logic.account.GroupLogic import dev.fyloz.colorrecipesexplorer.service.RecipeService